├── hacs.json ├── images ├── simple_timer_card.png ├── simple_timer_daily.png ├── simple_timer_running.png ├── simple_timer_bedroom_fan.png ├── simple_timer_card_editor.png ├── simple_timer_dashboard.png ├── simple_timer_minimalist.png ├── simple_timer_daily+timers.png ├── simple_timer_delay_running.png ├── simple_timer_kitchen_lights.png ├── simple_timer_card_configuration.png ├── simple_timer_daily+timers+title.png └── simple_timer_garden_sprinklers.png ├── custom_components └── simple_timer │ ├── const.py │ ├── brands │ └── simple_timer │ │ ├── icon.png │ │ └── logo.png │ ├── manifest.json │ ├── services.yaml │ ├── __init__.py │ └── config_flow.py ├── .github └── workflows │ ├── hacs.yaml │ ├── hassfest.yaml │ └── validate.yaml ├── tsconfig.json ├── package.json ├── rollup.config.js ├── info.md ├── .gitignore ├── src ├── global.d.ts ├── timer-card-editor.styles.ts ├── timer-card.styles.ts ├── timer-card-editor.ts └── timer-card.ts ├── README.md └── LICENSE /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Simple Timer", 3 | "content_in_root": false, 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /images/simple_timer_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_card.png -------------------------------------------------------------------------------- /images/simple_timer_daily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_daily.png -------------------------------------------------------------------------------- /images/simple_timer_running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_running.png -------------------------------------------------------------------------------- /images/simple_timer_bedroom_fan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_bedroom_fan.png -------------------------------------------------------------------------------- /images/simple_timer_card_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_card_editor.png -------------------------------------------------------------------------------- /images/simple_timer_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_dashboard.png -------------------------------------------------------------------------------- /images/simple_timer_minimalist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_minimalist.png -------------------------------------------------------------------------------- /images/simple_timer_daily+timers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_daily+timers.png -------------------------------------------------------------------------------- /images/simple_timer_delay_running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_delay_running.png -------------------------------------------------------------------------------- /custom_components/simple_timer/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Simple Timer integration.""" 2 | DOMAIN = "simple_timer" 3 | PLATFORMS = ["sensor"] -------------------------------------------------------------------------------- /images/simple_timer_kitchen_lights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_kitchen_lights.png -------------------------------------------------------------------------------- /images/simple_timer_card_configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_card_configuration.png -------------------------------------------------------------------------------- /images/simple_timer_daily+timers+title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_daily+timers+title.png -------------------------------------------------------------------------------- /images/simple_timer_garden_sprinklers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/images/simple_timer_garden_sprinklers.png -------------------------------------------------------------------------------- /custom_components/simple_timer/brands/simple_timer/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/custom_components/simple_timer/brands/simple_timer/icon.png -------------------------------------------------------------------------------- /custom_components/simple_timer/brands/simple_timer/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArikShemesh/ha-simple-timer/HEAD/custom_components/simple_timer/brands/simple_timer/logo.png -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: HACS Action 15 | uses: hacs/action@main 16 | with: 17 | category: integration -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Hassfest validation 17 | uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | hassfest: 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - uses: "actions/checkout@v4" 12 | - uses: home-assistant/actions/hassfest@master 13 | 14 | hacs: 15 | runs-on: "ubuntu-latest" 16 | steps: 17 | - uses: "actions/checkout@v4" 18 | - name: HACS validation 19 | uses: "hacs/action@main" 20 | with: 21 | category: "integration" -------------------------------------------------------------------------------- /custom_components/simple_timer/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "simple_timer", 3 | "name": "Simple Timer", 4 | "after_dependencies": ["http", "lovelace"], 5 | "codeowners": ["@ArikShemesh"], 6 | "config_flow": true, 7 | "dependencies": [], 8 | "documentation": "https://github.com/ArikShemesh/ha-simple-timer", 9 | "iot_class": "local_polling", 10 | "issue_tracker": "https://github.com/ArikShemesh/ha-simple-timer/issues", 11 | "loggers": ["custom_components.simple_timer"], 12 | "requirements": [], 13 | "version": "1.3.62" 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true, 4 | "target": "es2017", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "experimentalDecorators": true, 8 | "useDefineForClassFields": false, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "lib": ["es2017", "dom", "dom.iterable"], 13 | "noEmit": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitAny": false, 18 | "skipLibCheck": true, 19 | }, 20 | "include": [ 21 | "./src/**/*.ts", 22 | "./src/**/*.d.ts", 23 | "./src/global.d.ts" 24 | ], 25 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-timer-card", 3 | "version": "1.3.62", 4 | "description": "Custom simple timer integration for Home Assistant", 5 | "main": "timer-card.ts", 6 | "type": "module", 7 | "scripts": { 8 | "build": "rollup -c", 9 | "watch": "rollup -c --watch", 10 | "clean": "rd /s /q dist" 11 | }, 12 | "keywords": [ 13 | "home-assistant", 14 | "lovelace", 15 | "custom-card", 16 | "simple-timer", 17 | "typescript" 18 | ], 19 | "targets": { 20 | "module": { 21 | "includeNodeModules": true 22 | } 23 | }, 24 | "devDependencies": { 25 | "@rollup/plugin-commonjs": "^28.0.2", 26 | "@rollup/plugin-json": "^6.1.0", 27 | "@rollup/plugin-node-resolve": "^16.0.0", 28 | "@rollup/plugin-terser": "^0.4.4", 29 | "@rollup/plugin-typescript": "^12.1.2", 30 | "rollup-plugin-serve": "^1.1.1", 31 | "rollup": "^4.39.0" 32 | }, 33 | "dependencies": { 34 | "custom-card-helpers": "^1.9.0", 35 | "home-assistant-js-websocket": "^9.4.0", 36 | "lit": "^3.2.1" 37 | } 38 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import json from "@rollup/plugin-json"; 3 | import nodeResolve from "@rollup/plugin-node-resolve"; 4 | import terser from '@rollup/plugin-terser'; 5 | import typescript from "@rollup/plugin-typescript"; 6 | import serve from "rollup-plugin-serve"; 7 | import path from "path"; 8 | 9 | const isProduction = process.env.NODE_ENV === 'production'; 10 | const dev = process.env.ROLLUP_WATCH; 11 | 12 | const serveOptions = { 13 | contentBase: ["./dist"], 14 | host: "0.0.0.0", 15 | port: 4000, 16 | allowCrossOrigin: true, 17 | headers: { 18 | "Access-Control-Allow-Origin": "*", 19 | }, 20 | }; 21 | 22 | // Build output path inside the integration's dist folder 23 | const integrationDist = path.resolve( 24 | "custom_components", 25 | "simple_timer", 26 | "dist" 27 | ); 28 | 29 | export default [ 30 | { 31 | input: "src/timer-card.ts", 32 | output: { 33 | file: path.join(integrationDist, "timer-card.js"), 34 | format: "es", 35 | inlineDynamicImports: true, 36 | sourcemap: !isProduction, 37 | }, 38 | plugins: [ 39 | typescript({ 40 | declaration: false 41 | }), 42 | nodeResolve(), 43 | json(), 44 | commonjs(), 45 | ...(dev ? [serve(serveOptions)] : [terser()]), 46 | ] 47 | }, 48 | ]; -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Simple Timer Integration 2 | 3 | A professional timer integration for Home Assistant with precise countdown functionality and daily runtime tracking. 4 | 5 | ## Key Features 6 | 7 | 🕐 **Precise Timer Control** - Set countdown timers from 1-1000 minutes for any switch, input_boolean, light, or fan 8 | 9 | 📊 **Daily Runtime Tracking** - Automatically tracks and displays daily usage time in HH:MM format 10 | 11 | 🔄 **Smart Auto-Cancel** - Timer automatically cancels if the controlled device is turned off externally 12 | 13 | 🎨 **Professional Timer Card** - Beautiful, modern UI with customizable timer buttons and real-time countdown 14 | 15 | 🔔 **Notification Support** - Optional notifications for timer start, finish, and cancellation events 16 | 17 | 🌙 **Midnight Reset** - Daily usage statistics reset automatically at midnight 18 | 19 | ## Perfect For 20 | 21 | - **Kitchen Timers** - Control smart switches for appliances 22 | - **Boiler Control** - Manage water heater schedules 23 | - **Garden Irrigation** - Time watering systems 24 | - **Lighting Control** - Automatic light timers 25 | - **HVAC Management** - Climate control scheduling 26 | 27 | ## Easy Setup 28 | 29 | 1. Install via HACS 30 | 2. Add integration and select your device 31 | 3. Add the timer card to your dashboard 32 | 4. Customize timer buttons and notifications 33 | 34 | No complex configuration required - works out of the box with professional results! -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .nox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyderworkspace 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | .dmypy.json 106 | dmypy.json 107 | 108 | # Node.js 109 | node_modules/ 110 | npm-debug.log* 111 | yarn-debug.log* 112 | yarn-error.log* 113 | package-lock.json 114 | 115 | # VS Code 116 | .vscode/ 117 | 118 | # macOS 119 | .DS_Store 120 | 121 | # Windows 122 | Thumbs.db 123 | /*.docx 124 | /*.bat 125 | /*.pdf 126 | /*.html 127 | /*.bak 128 | /notepad session 129 | /*.tmp 130 | /custom_components/simple_timer/dist/*.map 131 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | // global.d.ts 2 | 3 | interface TimerCardConfig { 4 | type: string; 5 | timer_instance_id?: string | null; 6 | entity?: string | null; 7 | sensor_entity?: string | null; 8 | timer_buttons: (number | string)[]; 9 | card_title?: string | null; 10 | power_button_icon?: string | null; 11 | slider_max?: number; 12 | slider_unit?: string; 13 | reverse_mode?: boolean; 14 | hide_slider?: boolean; 15 | show_daily_usage?: boolean; 16 | slider_thumb_color?: string | null; 17 | slider_background_color?: string | null; 18 | timer_button_font_color?: string | null; 19 | timer_button_background_color?: string | null; 20 | power_button_background_color?: string | null; 21 | power_button_icon_color?: string | null; 22 | } 23 | 24 | // Define the structure for a Home Assistant state object 25 | interface HAState { 26 | entity_id: string; 27 | state: string; 28 | attributes: { 29 | friendly_name?: string; 30 | entry_id?: string; 31 | switch_entity_id?: string; 32 | timer_state?: 'active' | 'idle'; 33 | timer_finishes_at?: string; 34 | timer_duration?: number; 35 | watchdog_message?: string; 36 | show_seconds?: boolean; // NEW: This comes from backend now 37 | [key: string]: any; // Allow for other unknown attributes 38 | }; 39 | last_changed: string; 40 | last_updated: string; 41 | context: { 42 | id: string; 43 | parent_id: string | null; 44 | user_id: string | null; 45 | }; 46 | } 47 | 48 | // Define the structure for a Home Assistant service object 49 | interface HAService { 50 | description: string; 51 | fields: { 52 | [field: string]: { 53 | description: string; 54 | example: string; 55 | }; 56 | }; 57 | } 58 | 59 | interface HomeAssistant { 60 | // Correctly define states as an index signature 61 | states: { 62 | [entityId: string]: HAState; 63 | }; 64 | // Correctly define services with specific domains and services 65 | services: { 66 | notify?: { [service: string]: HAService }; 67 | switch?: { [service: string]: HAService }; 68 | [domain: string]: { [service: string]: HAService } | undefined; // Allow other domains 69 | }; 70 | callService(domain: string, service: string, data?: Record): Promise; 71 | callApi(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, parameters?: Record, headers?: Record): Promise; 72 | config: { 73 | components: { 74 | [domain: string]: { 75 | config_entries: { [entry_id: string]: unknown }; 76 | }; 77 | }; 78 | [key: string]: any; 79 | }; 80 | } 81 | 82 | // New Interfaces for config entries API response 83 | interface HAConfigEntry { 84 | entry_id: string; 85 | title: string; 86 | domain: string; 87 | } 88 | 89 | interface HAConfigEntriesByDomainResponse { 90 | entry_by_domain: { 91 | [domain: string]: HAConfigEntry[]; 92 | }; 93 | } 94 | 95 | interface Window { 96 | customCards: Array<{ 97 | type: string; 98 | name: string; 99 | description: string; 100 | }>; 101 | } -------------------------------------------------------------------------------- /custom_components/simple_timer/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the services for the Simple Timer integration 2 | start_timer: 3 | name: Start Timer 4 | description: Starts a countdown timer for the device. The associated switch is turned on. 5 | fields: 6 | entry_id: 7 | name: Entry ID 8 | description: The config entry ID of the simple timer sensor. 9 | required: true 10 | selector: 11 | text: 12 | duration: 13 | name: Duration 14 | description: The duration of the timer in minutes. 15 | required: true 16 | selector: 17 | number: 18 | min: 1 19 | max: 1000 20 | unit_of_measurement: "minutes" 21 | reverse_mode: 22 | name: Reverse Mode 23 | description: If true, starts a delayed timer (device turns ON when timer finishes). 24 | required: false 25 | default: false 26 | selector: 27 | boolean: 28 | 29 | cancel_timer: 30 | name: Cancel Timer 31 | description: Cancels an active countdown timer. 32 | fields: 33 | entry_id: 34 | name: Entry ID 35 | description: The config entry ID of the simple timer sensor. 36 | required: true 37 | selector: 38 | text: 39 | 40 | update_switch_entity: 41 | name: Update Switch Entity 42 | description: Tells the sensor which switch entity to monitor for runtime calculation. 43 | fields: 44 | entry_id: 45 | name: Entry ID 46 | description: The config entry ID of the simple timer sensor. 47 | required: true 48 | selector: 49 | text: 50 | switch_entity_id: 51 | name: Switch Entity 52 | description: The entity ID of the switch to monitor (e.g., switch.my_timer). 53 | required: true 54 | selector: 55 | entity: 56 | domain: switch 57 | 58 | force_name_sync: 59 | name: Force Name Sync 60 | description: Forces immediate synchronization of sensor names after entry rename. 61 | fields: 62 | entry_id: 63 | name: Entry ID (Optional) 64 | description: The config entry ID to sync. If omitted, syncs all Simple Timer sensors. 65 | required: false 66 | selector: 67 | text: 68 | 69 | manual_power_toggle: 70 | name: Manual Power Toggle 71 | description: Manually toggles switch power and sends appropriate notifications. 72 | fields: 73 | entry_id: 74 | name: Entry ID 75 | description: The config entry ID of the simple timer sensor. 76 | required: true 77 | selector: 78 | text: 79 | action: 80 | name: Action 81 | description: The action to perform (turn_on or turn_off). 82 | required: true 83 | selector: 84 | select: 85 | options: 86 | - turn_on 87 | - turn_off 88 | 89 | reset_daily_usage: 90 | name: Reset Daily Usage 91 | description: Manually reset daily usage time to zero 92 | fields: 93 | entry_id: 94 | name: Entry ID 95 | description: The config entry ID of the simple timer sensor 96 | required: true 97 | selector: 98 | text: 99 | 100 | test_notification: 101 | name: Test Notification 102 | description: Test the notification functionality for a timer instance 103 | fields: 104 | entry_id: 105 | name: Entry ID 106 | description: The config entry ID of the simple timer sensor 107 | required: true 108 | selector: 109 | text: 110 | message: 111 | name: Message 112 | description: Test message to send 113 | required: false 114 | default: "Test notification" 115 | selector: 116 | text: 117 | 118 | reload_resources: 119 | name: Reload Frontend Resources 120 | description: Manually reload and update the Simple Timer card frontend resources with the current version 121 | fields: {} -------------------------------------------------------------------------------- /src/timer-card-editor.styles.ts: -------------------------------------------------------------------------------- 1 | // timer-card-editor.styles.ts 2 | 3 | import { css } from 'lit'; 4 | 5 | export const editorCardStyles = css` 6 | .card-config-group { 7 | padding: 16px; 8 | background-color: var(--card-background-color); 9 | border-top: 1px solid var(--divider-color); 10 | margin-top: 16px; 11 | } 12 | h3 { 13 | margin-top: 0; 14 | margin-bottom: 16px; 15 | font-size: 1.1em; 16 | font-weight: normal; 17 | color: var(--primary-text-color); 18 | } 19 | .checkbox-grid { 20 | display: grid; 21 | grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); 22 | gap: 8px 16px; 23 | margin-bottom: 16px; 24 | } 25 | @media (min-width: 400px) { 26 | .checkbox-grid { 27 | grid-template-columns: repeat(5, 1fr); 28 | } 29 | } 30 | .checkbox-label { 31 | display: flex; 32 | align-items: center; 33 | cursor: pointer; 34 | color: var(--primary-text-color); 35 | } 36 | .checkbox-label input[type="checkbox"] { 37 | margin-right: 8px; 38 | min-width: 20px; 39 | min-height: 20px; 40 | } 41 | .timer-buttons-info { 42 | padding: 12px; 43 | background-color: var(--secondary-background-color); 44 | border-radius: 8px; 45 | border: 1px solid var(--divider-color); 46 | } 47 | .timer-buttons-info p { 48 | margin: 4px 0; 49 | font-size: 14px; 50 | color: var(--primary-text-color); 51 | } 52 | .warning-text { 53 | color: var(--warning-color); 54 | font-weight: bold; 55 | } 56 | .info-text { 57 | color: var(--primary-text-color); 58 | font-style: italic; 59 | } 60 | 61 | .card-config { 62 | padding: 16px; 63 | } 64 | .config-row { 65 | margin-bottom: 16px; 66 | } 67 | .config-row ha-textfield, 68 | .config-row ha-select { 69 | width: 100%; 70 | } 71 | .config-row ha-formfield { 72 | display: flex; 73 | align-items: center; 74 | } 75 | 76 | /* Timer Chips UI */ 77 | .timer-chips-container { 78 | margin-bottom: 8px; 79 | } 80 | 81 | .chips-wrapper { 82 | display: flex; 83 | flex-wrap: wrap; 84 | gap: 8px; 85 | min-height: 40px; 86 | padding: 8px 0; 87 | } 88 | 89 | .timer-chip { 90 | display: flex; 91 | align-items: center; 92 | background-color: var(--secondary-background-color); 93 | border: 1px solid var(--divider-color); 94 | border-radius: 16px; 95 | padding: 4px 12px; 96 | font-size: 14px; 97 | color: var(--primary-text-color); 98 | transition: background-color 0.2s; 99 | } 100 | 101 | .timer-chip:hover { 102 | background-color: var(--secondary-text-color); 103 | color: var(--primary-background-color); 104 | } 105 | 106 | .remove-chip { 107 | margin-left: 8px; 108 | cursor: pointer; 109 | font-weight: bold; 110 | opacity: 0.6; 111 | display: flex; 112 | align-items: center; 113 | justify-content: center; 114 | width: 16px; 115 | height: 16px; 116 | border-radius: 50%; 117 | } 118 | 119 | .remove-chip:hover { 120 | opacity: 1; 121 | background-color: rgba(0,0,0,0.1); 122 | } 123 | 124 | .add-timer-row { 125 | display: flex; 126 | align-items: center; 127 | gap: 8px; 128 | margin-top: 8px; 129 | } 130 | 131 | .add-btn { 132 | background-color: var(--primary-color); 133 | color: var(--text-primary-color); 134 | padding: 0 16px; 135 | height: 56px; /* Match textfield height */ 136 | display: flex; 137 | align-items: center; 138 | justify-content: center; 139 | border-radius: 4px; 140 | cursor: pointer; 141 | font-weight: 500; 142 | text-transform: uppercase; 143 | letter-spacing: 0.5px; 144 | margin-top: -6px; /* Align slightly better with textfield label offset */ 145 | } 146 | .add-btn:hover { 147 | opacity: 0.9; 148 | } 149 | .add-btn:active { 150 | opacity: 0.7; 151 | } 152 | `; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/ArikShemesh/ha-simple-timer/blob/main/custom_components/simple_timer/brands/simple_timer/logo.png) 2 | 3 | 4 | # HA Simple Timer Integration (+ Card) 5 | A simple Home Assistant integration that turns entities on and off with a precise countdown timer and daily runtime tracking. 6 | 7 | Buy Me A Coffee 8 | 9 | ![image](https://github.com/ArikShemesh/ha-simple-timer/blob/main/images/simple_timer_dashboard.png) 10 | 11 | ### Configuration 12 | ![image](https://github.com/ArikShemesh/ha-simple-timer/blob/main/images/simple_timer_card_configuration.png) 13 | 14 | ## ✨ Key Features 15 | 🚀 **Out-of-the-box**, pre-packaged timer solution, eliminating manual creation of multiple Home Assistant entities, sensors, and automations. 16 | 17 | 🕐 **Precise Timer Control** - Set countdown timers from 1-1000 minutes for any switch, input_boolean, light, or fan 18 | 19 | 📊 **Daily Runtime Tracking** - Automatically tracks and displays daily usage time 20 | 21 | 🔄 **Smart Auto-Cancel** - Timer automatically cancels if the controlled device is turned off externally 22 | 23 | 🎨 **Professional Timer Card** - Beautiful, modern UI with customizable timer buttons and real-time countdown 24 | 25 | 🔔 **Notification Support** - Optional notifications for timer start, finish, and cancellation events 26 | 27 | 🌙 **Midnight Reset** - Daily usage statistics reset automatically at midnight 28 | 29 | ⏰ **Delayed Start Timers** - Turns devices ON when timer completes and keeps them on indefinitely until manually turned off 30 | 31 | ## 🏠 Perfect For 32 | 33 | - **Water Heater Control** - Manage boiler schedules 34 | - **Kitchen Timers** - Control smart switches for appliances 35 | - **Garden Irrigation** - Time watering systems 36 | - **Lighting Control** - Automatic light timers 37 | - **Fan Control** - Bathroom or ventilation fans 38 | - **Any Timed Device** - Universal timer for any switchable device 39 | 40 | ## 📦 Installation 41 | 42 | ### HACS (Recommended) 43 | 44 | ⚠️ If you previously added this integration as a custom repository in HACS, it's recommended to remove the custom entry and reinstall it from the official HACS store. 45 | You will continue to receive updates in both cases, but switching ensures you're aligned with the official listing and avoids potential issues in the future. 46 | 47 | Use this link to open the repository in HACS and click on Download 48 | 49 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=ArikShemesh&repository=ha-simple-timer) 50 | 51 | ### Manual Installation 52 | 1. Download the latest release from [GitHub Releases](https://github.com/ArikShemesh/ha-simple-timer/releases) 53 | 2. Extract the `custom_components/simple_timer` folder to your Home Assistant `custom_components` directory 54 | 3. **Restart Home Assistant** 55 | 56 | **That's it!** The timer card is automatically installed and ready to use - no additional steps required. 57 | 58 | ## ⚙️ Configuration 59 | 60 | ### Add Integration Instance 61 | 1. Go to **Settings → Devices & Services** 62 | 2. Click **"Add Integration"** 63 | 3. Search for **"Simple Timer"** 64 | 4. Select the device you want to control (switch, light, fan, input_boolean) 65 | 5. Give your timer instance a descriptive name (e.g., "Kitchen Timer", "Water Heater") 66 | 6. Choose notification entitiy (optional) - can be add more than one 67 | 7. Check show seconds (optional) - display seconds in uasge time and notifications 68 | 69 | ### Add Timer Card to Dashboard 70 | 1. **Edit your dashboard** 71 | 2. **Add a card** 72 | 3. Search for **"Simple Timer Card"** (should appear in the card picker) 73 | 4. **Configure the card:** 74 | - Select your timer instance 75 | - Customize timer buttons 76 | - Add a custom card title (optional) 77 | 78 | ## 🔄 Renaming Timer Instances 79 | 80 | ### ✅ Recommended Method 81 | 1. Go to **Settings → Devices & Services** 82 | 2. Find your Simple Timer integration 83 | 3. Click **Configure** (⚙️ gear icon) 84 | 4. Change the name and save 85 | 86 | ### 💡 Note on 3-Dots Rename 87 | If you use the 3-dots menu to rename, open **Configure** once afterward to sync the change. 88 | 89 | ## 🎛️ Card Configuration 90 | 91 | ### Visual Configuration (Recommended) 92 | Use the card editor in the Home Assistant UI for easy configuration. 93 | 94 | ### YAML Configuration 95 | ```yaml 96 | type: custom:timer-card 97 | timer_instance_id: your_instance_entry_id 98 | timer_buttons: [15, 30, 60, 90, 120, 150] 99 | card_title: "Kitchen Timer" 100 | slider_max: 120 101 | reverse_mode: true 102 | show_daily_usage: true 103 | power_icon: "mdi:power" 104 | ``` 105 | 106 | ### Configuration Options 107 | 108 | Option | Type | Default | Description 109 | ----------------------|----------|--------------------------|------------------------------------------------------- 110 | `type` | string | - | Must be `custom:timer-card` 111 | `timer_instance_id` | string | - | Entry ID of your timer instance 112 | `timer_buttons` | array | [15,30,60,90,120,150] | Timer duration buttons (1-1000 minutes) 113 | `card_title` | string | - | Custom title for the card 114 | `slider_max` | integer | 120 | Slider max value (1-1000 minutes) 115 | `reverse_mode` | boolean | false | Enable or disabled the delayed start feature 116 | `show_daily_usage` | boolean | false | Display or hide the daily usage 117 | `power_icon` | mdi | mdi:power | Set the power button icon 118 | 119 | ## ❓ Frequently Asked Questions 120 | 121 | ### Can I have multiple timer instances? 122 | Yes! Add multiple integrations for different devices. 123 | 124 | ### Does the timer work if Home Assistant restarts? 125 | Yes, active timers resume automatically with offline time compensation. 126 | 127 | ### Can I customize the timer buttons? 128 | Yes, configure any timer value between 1-1000 `timer_buttons: [7, 13, 25, 1000]` in the card YAML. 129 | 130 | ### Why does my usage show a warning message? 131 | This appears when HA was offline during a timer to indicate potential time sync issues. 132 | 133 | ## 🚨 Troubleshooting 134 | 135 | ### Card Not Appearing in Card Picker 136 | 137 | 1. **Restart Home Assistant:** The card is installed during integration setup 138 | 2. **Check integration logs:** Look for any errors during the card installation process 139 | 3. **Verify automatic installation:** Check if `/config/www/simple-timer/timer-card.js` exists 140 | 4. **Clear browser cache:** Hard refresh with Ctrl+F5 (Windows) or Cmd+Shift+R (Mac) 141 | 5. **Check browser console:** Press F12 and look for JavaScript errors 142 | 143 | ### Timer Not Working 144 | 145 | 1. **Check device entity:** Ensure the controlled device exists and is accessible 146 | 2. **Verify integration setup:** Go to Settings → Devices & Services → Simple Timer 147 | 3. **Check logs:** Look for errors in Settings → System → Logs 148 | 4. **Restart integration:** Remove and re-add the integration if needed 149 | 150 | ### Daily Usage Not Tracking 151 | 152 | 1. **Device state changes:** Timer only tracks when the device is actually ON 153 | 2. **Manual control:** If you turn the device off manually, tracking stops (by design) 154 | 3. **Midnight reset:** Usage resets at 00:00 each day automatically 155 | 156 | ### Card Installation Issues 157 | 158 | If the automatic card installation fails: 159 | 1. **Check file permissions:** Ensure Home Assistant can write to the `www` directory 160 | 2. **Verify disk space:** Ensure sufficient space for file copying 161 | 3. **Check integration logs:** Look for specific error messages 162 | 4. **Manual fallback:** You can still manually copy the card file from the integration's `dist` folder 163 | 164 | ## 📝 Getting Help 165 | 166 | If you encounter issues: 167 | 168 | 1. **Check the [Issues](https://github.com/ArikShemesh/ha-simple-timer/issues)** page for existing solutions 169 | 2. **Enable debug logging:** 170 | ```yaml 171 | logger: 172 | logs: 173 | custom_components.simple_timer: debug 174 | ``` 175 | 3. **Create a new issue** with: 176 | - Home Assistant version 177 | - Integration version 178 | - Detailed error description 179 | - Relevant log entries 180 | 181 | ## 🤝 Contributing 182 | 183 | Contributions are welcome! Please feel free to submit a Pull Request. 184 | 185 | 1. Fork the repository 186 | 2. Create a feature branch 187 | 3. Make your changes 188 | 4. Test thoroughly 189 | 5. Submit a pull request 190 | 191 | ## 📄 License 192 | 193 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 194 | 195 | ## ⭐ Support 196 | 197 | If you find this integration useful, please consider: 198 | - ⭐ **Starring this repository** 199 | - 🐛 **Reporting bugs** you encounter 200 | - 💡 **Suggesting new features** 201 | - 📖 **Improving documentation** 202 | 203 | --- 204 | 205 | ## Star History 206 | 207 | ## Star History 208 | 209 | 210 | 211 | 212 | 213 | Star History Chart 214 | 215 | 216 | 217 | **Made with ❤️ for the Home Assistant community** 218 | -------------------------------------------------------------------------------- /src/timer-card.styles.ts: -------------------------------------------------------------------------------- 1 | // timer-card.styles.ts 2 | 3 | import { css } from 'lit'; 4 | 5 | export const cardStyles = css` 6 | :host { 7 | display: block; 8 | } 9 | 10 | ha-card { 11 | padding: 0; 12 | position: relative; 13 | } 14 | 15 | .card-header { 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | font-size: 1.5em; 20 | font-weight: bold; 21 | text-align: center; 22 | padding: 0px; 23 | color: var(--primary-text-color); 24 | border-radius: 12px 12px 0 0; 25 | margin-bottom: 0px; 26 | } 27 | 28 | .card-header.has-title { 29 | margin-bottom: -15px; 30 | } 31 | 32 | .card-title { 33 | font-family: 'Roboto', sans-serif; 34 | font-weight: 500; 35 | font-size: 1.7rem; 36 | color: rgba(160,160,160,0.7); 37 | text-align: left; 38 | margin: 0; 39 | padding: 0 8px; 40 | } 41 | 42 | .placeholder { 43 | padding: 16px; 44 | background-color: var(--secondary-background-color); 45 | } 46 | 47 | .warning { 48 | padding: 16px; 49 | color: white; 50 | background-color: var(--error-color); 51 | } 52 | 53 | /* New layout styles */ 54 | .card-content { 55 | padding: 12px !important; 56 | padding-top: 0px !important; 57 | margin: 0 !important; 58 | } 59 | 60 | .countdown-section { 61 | text-align: center; 62 | padding: 0 !important; 63 | display: flex; 64 | flex-direction: column; 65 | align-items: center; 66 | justify-content: center; 67 | } 68 | 69 | .countdown-display { 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | font-size: 3.5rem; 74 | font-weight: bold; 75 | width: 100%; 76 | text-align: center; 77 | white-space: nowrap; 78 | overflow: hidden; 79 | text-overflow: ellipsis; 80 | line-height: 1.2; 81 | padding: 4px 0; 82 | min-height: 3.5rem; 83 | box-sizing: border-box; 84 | } 85 | 86 | .countdown-display.active { 87 | color: var(--primary-color); 88 | } 89 | 90 | .countdown-display.active.reverse { 91 | color: #f2ba5a; 92 | } 93 | 94 | .daily-usage-display { 95 | font-size: 1rem; 96 | color: var(--secondary-text-color); 97 | text-align: center; 98 | margin-top: -8px; 99 | white-space: nowrap; 100 | overflow: hidden; 101 | text-overflow: ellipsis; 102 | } 103 | 104 | .slider-row { 105 | display: flex; 106 | align-items: center; 107 | gap: 4px; 108 | margin-bottom: 15px; 109 | flex-wrap: wrap; 110 | justify-content: center; 111 | } 112 | 113 | .slider-container { 114 | flex: 0 0 75%; 115 | display: flex; 116 | align-items: center; 117 | gap: 8px; 118 | } 119 | 120 | .timer-slider { 121 | flex: 1; 122 | height: 20px; 123 | -webkit-appearance: none; 124 | appearance: none; 125 | background: var(--secondary-background-color); 126 | border-radius: 20px; 127 | outline: none; 128 | } 129 | 130 | .timer-slider::-webkit-slider-thumb { 131 | -webkit-appearance: none; 132 | appearance: none; 133 | width: 30px; 134 | height: 30px; 135 | border-radius: 50%; 136 | background: #2ab69c; 137 | cursor: pointer; 138 | border: 2px solid #4bd9bf; 139 | box-shadow: 140 | 0 0 0 2px rgba(75, 217, 191, 0.3), 141 | 0 0 8px rgba(42, 182, 156, 0.4), 142 | 0 2px 4px rgba(0, 0, 0, 0.2); 143 | transition: all 0.2s ease; 144 | } 145 | 146 | .timer-slider::-webkit-slider-thumb:hover { 147 | background: #239584; 148 | border: 2px solid #4bd9bf; 149 | box-shadow: 150 | 0 0 0 3px rgba(75, 217, 191, 0.4), 151 | 0 0 12px rgba(42, 182, 156, 0.6), 152 | 0 2px 6px rgba(0, 0, 0, 0.3); 153 | transform: scale(1.05); 154 | } 155 | 156 | .timer-slider::-webkit-slider-thumb:active { 157 | background: #1e7e6f; 158 | border: 2px solid #4bd9bf; 159 | box-shadow: 160 | 0 0 0 4px rgba(75, 217, 191, 0.5), 161 | 0 0 16px rgba(42, 182, 156, 0.7), 162 | 0 2px 8px rgba(0, 0, 0, 0.4); 163 | transform: scale(0.98); 164 | } 165 | 166 | .timer-slider::-moz-range-thumb { 167 | width: 30px; 168 | height: 30px; 169 | border-radius: 50%; 170 | background: #2ab69c; 171 | cursor: pointer; 172 | border: 2px solid #4bd9bf; 173 | box-shadow: 174 | 0 0 0 2px rgba(75, 217, 191, 0.3), 175 | 0 0 8px rgba(42, 182, 156, 0.4), 176 | 0 2px 4px rgba(0, 0, 0, 0.2); 177 | transition: all 0.2s ease; 178 | } 179 | 180 | .timer-slider::-moz-range-thumb:hover { 181 | background: #239584; 182 | border: 2px solid #4bd9bf; 183 | box-shadow: 184 | 0 0 0 3px rgba(75, 217, 191, 0.4), 185 | 0 0 12px rgba(42, 182, 156, 0.6), 186 | 0 2px 6px rgba(0, 0, 0, 0.3); 187 | transform: scale(1.05); 188 | } 189 | 190 | .timer-slider::-moz-range-thumb:active { 191 | background: #1e7e6f; 192 | border: 2px solid #4bd9bf; 193 | box-shadow: 194 | 0 0 0 4px rgba(75, 217, 191, 0.5), 195 | 0 0 16px rgba(42, 182, 156, 0.7), 196 | 0 2px 8px rgba(0, 0, 0, 0.4); 197 | transform: scale(0.98); 198 | } 199 | 200 | .slider-label { 201 | font-size: 16px; 202 | font-weight: 500; 203 | color: var(--primary-text-color); 204 | min-width: 60px; 205 | text-align: left; 206 | } 207 | 208 | .power-button-small { 209 | width: 65px; 210 | height: 60px; 211 | flex-shrink: 0; 212 | box-sizing: border-box; 213 | border-radius: 12px; 214 | display: flex; 215 | flex-direction: column; 216 | align-items: center; 217 | justify-content: center; 218 | cursor: pointer; 219 | transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); 220 | position: relative; 221 | background-color: var(--secondary-background-color); 222 | border: 2px solid transparent; 223 | background-clip: padding-box; 224 | box-shadow: 225 | 0 8px 25px rgba(0, 0, 0, 0.4), 226 | 0 3px 10px rgba(0, 0, 0, 0.3), 227 | inset 0 1px 0 rgba(255, 255, 255, 0.2), 228 | inset 0 -1px 0 rgba(0, 0, 0, 0.3); 229 | 230 | color: var(--primary-color); 231 | --mdc-icon-size: 36px; 232 | padding: 4px; 233 | } 234 | 235 | .power-button-small ha-icon[icon] { 236 | color: var(--primary-color); 237 | } 238 | 239 | .power-button-small.reverse ha-icon[icon] { 240 | color: #f2ba5a; 241 | } 242 | 243 | .power-button-small::before { 244 | content: ''; 245 | position: absolute; 246 | inset: -2px; 247 | border-radius: 14px; 248 | z-index: -1; 249 | } 250 | 251 | .power-button-small:hover { 252 | transform: translateY(-2px); 253 | box-shadow: 254 | 0 12px 35px rgba(0, 0, 0, 0.5), 255 | 0 5px 15px rgba(0, 0, 0, 0.4), 256 | inset 0 1px 0 rgba(255, 255, 255, 0.25), 257 | inset 0 -1px 0 rgba(0, 0, 0, 0.3); 258 | color: var(--primary-color); 259 | } 260 | 261 | .power-button-small:active { 262 | transform: translateY(0px); 263 | transition: all 0.1s; 264 | box-shadow: 265 | 0 4px 15px rgba(0, 0, 0, 0.4), 266 | 0 2px 5px rgba(0, 0, 0, 0.3), 267 | inset 0 1px 0 rgba(255, 255, 255, 0.15), 268 | inset 0 -1px 0 rgba(0, 0, 0, 0.4); 269 | } 270 | 271 | .power-button-small.on { 272 | border: 2px solid #4da3e0; 273 | color: var(--primary-color); 274 | box-shadow: 275 | 0 0 0 2px rgba(42, 137, 209, 0.3), 276 | 0 0 12px rgba(42, 137, 209, 0.6); 277 | animation: pulse 2s infinite; 278 | } 279 | 280 | .power-button-small.on::before { 281 | display: none; 282 | } 283 | 284 | @keyframes pulse { 285 | 0%, 100% { box-shadow: 286 | 0 0 0 2px rgba(42, 137, 209, 0.3), 287 | 0 0 12px rgba(42, 137, 209, 0.6); } 288 | 50% { box-shadow: 289 | 0 0 0 4px rgba(42, 137, 209, 0.5), 290 | 0 0 20px rgba(42, 137, 209, 0.8); } 291 | } 292 | 293 | .power-button-small.on.reverse { 294 | border: 2px solid #f4c474; 295 | color: #f2ba5a; 296 | box-shadow: 297 | 0 0 0 2px rgba(242, 186, 90, 0.3), 298 | 0 0 12px rgba(242, 186, 90, 0.6); 299 | animation: pulse-orange 2s infinite; 300 | } 301 | 302 | @keyframes pulse-orange { 303 | 0%, 100% { box-shadow: 304 | 0 0 0 2px rgba(242, 186, 90, 0.3), 305 | 0 0 12px rgba(242, 186, 90, 0.6); } 306 | 50% { box-shadow: 307 | 0 0 0 4px rgba(242, 186, 90, 0.5), 308 | 0 0 20px rgba(242, 186, 90, 0.8); } 309 | } 310 | 311 | .button-grid { 312 | display: flex; 313 | flex-wrap: wrap; 314 | gap: 8px; 315 | justify-content: center; 316 | } 317 | 318 | .timer-button { 319 | width: 80px; 320 | height: 65px; 321 | border-radius: 12px; 322 | display: flex; 323 | flex-direction: column; 324 | align-items: center; 325 | justify-content: center; 326 | cursor: pointer; 327 | transition: background-color 0.2s, opacity 0.2s; 328 | text-align: center; 329 | background-color: var(--secondary-background-color); 330 | color: var(--primary-text-color); 331 | } 332 | 333 | .timer-button:hover { 334 | box-shadow: 0 0 8px rgba(42, 182, 156, 1); 335 | } 336 | 337 | .timer-button.active { 338 | color: white; 339 | box-shadow: 0 0 8px rgba(42, 182, 156, 1); 340 | } 341 | 342 | .timer-button.active:hover { 343 | box-shadow: 0 0 12px rgba(42, 182, 156, 0.6); 344 | } 345 | 346 | .timer-button.disabled { 347 | opacity: 0.5; 348 | cursor: not-allowed; 349 | } 350 | 351 | .timer-button.disabled:hover { 352 | box-shadow: none; 353 | opacity: 0.5; 354 | } 355 | 356 | .timer-button-value { 357 | font-size: 20px; 358 | font-weight: 600; 359 | line-height: 1; 360 | } 361 | 362 | .timer-button-unit { 363 | font-size: 12px; 364 | font-weight: 400; 365 | margin-top: 2px; 366 | } 367 | 368 | .status-message { 369 | display: flex; 370 | align-items: center; 371 | padding: 8px 12px; 372 | margin: 0 0 12px 0; 373 | border-radius: 8px; 374 | border: 1px solid var(--warning-color); 375 | background-color: rgba(var(--rgb-warning-color), 0.1); 376 | } 377 | 378 | .status-icon { 379 | color: var(--warning-color); 380 | margin-right: 8px; 381 | } 382 | 383 | .status-text { 384 | font-size: 14px; 385 | color: var(--primary-text-color); 386 | } 387 | 388 | .watchdog-banner { 389 | margin: 0 0 12px 0; 390 | border-radius: 0; 391 | } 392 | 393 | .power-button-top-right { 394 | position: absolute; 395 | top: 12px; 396 | right: 12px; 397 | width: 40px; 398 | height: 40px; 399 | border-radius: 8px; 400 | display: flex; 401 | align-items: center; 402 | justify-content: center; 403 | cursor: pointer; 404 | background-color: var(--secondary-background-color); 405 | color: var(--primary-color); 406 | box-shadow: 407 | 0 2px 5px rgba(0, 0, 0, 0.2), 408 | inset 0 1px 0 rgba(255, 255, 255, 0.1); 409 | transition: all 0.2s ease; 410 | z-index: 5; 411 | } 412 | 413 | .power-button-top-right ha-icon { 414 | --mdc-icon-size: 24px; 415 | color: var(--primary-color); 416 | } 417 | 418 | .power-button-top-right:hover { 419 | background-color: var(--primary-background-color); 420 | transform: scale(1.05); 421 | } 422 | 423 | .power-button-top-right:active { 424 | transform: scale(0.95); 425 | } 426 | 427 | .power-button-top-right.on { 428 | color: var(--primary-color); 429 | box-shadow: 0 0 8px rgba(42, 137, 209, 0.6); 430 | border: 1px solid rgba(42, 137, 209, 0.5); 431 | animation: pulse 2s infinite; 432 | } 433 | 434 | .power-button-top-right.on.reverse { 435 | color: #f2ba5a; 436 | box-shadow: 0 0 8px rgba(242, 186, 90, 0.6); 437 | border: 1px solid rgba(242, 186, 90, 0.5); 438 | animation: pulse-orange 2s infinite; 439 | } 440 | `; -------------------------------------------------------------------------------- /custom_components/simple_timer/__init__.py: -------------------------------------------------------------------------------- 1 | """The Simple Timer integration.""" 2 | import voluptuous as vol 3 | import logging 4 | import os 5 | import json 6 | import shutil 7 | import asyncio 8 | import homeassistant.helpers.config_validation as cv 9 | 10 | from homeassistant.core import HomeAssistant, ServiceCall 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.components.http import StaticPathConfig 13 | from homeassistant.components.frontend import async_register_built_in_panel, add_extra_js_url 14 | from homeassistant.components.lovelace.resources import ResourceStorageCollection 15 | 16 | from .const import DOMAIN, PLATFORMS 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | def _copy_file_sync(source_file: str, dest_file: str, www_dir: str) -> bool: 21 | """Synchronous file copy function to be run in executor.""" 22 | try: 23 | # Create www/simple-timer directory if it doesn't exist 24 | os.makedirs(www_dir, exist_ok=True) 25 | 26 | # Copy the file if source exists 27 | if os.path.exists(source_file): 28 | shutil.copy2(source_file, dest_file) 29 | return True 30 | else: 31 | return False 32 | except Exception: 33 | return False 34 | 35 | async def copy_frontend_files(hass: HomeAssistant) -> bool: 36 | """Copy frontend files from integration dist folder to www folder.""" 37 | try: 38 | # Source: custom_components/simple_timer/dist/timer-card.js 39 | integration_path = os.path.dirname(__file__) 40 | source_file = os.path.join(integration_path, "dist", "timer-card.js") 41 | 42 | # Destination: config/www/simple-timer/timer-card.js 43 | www_dir = hass.config.path("www", "simple-timer") 44 | dest_file = os.path.join(www_dir, "timer-card.js") 45 | 46 | # Run the file copy in executor to avoid blocking I/O 47 | success = await hass.async_add_executor_job( 48 | _copy_file_sync, source_file, dest_file, www_dir 49 | ) 50 | 51 | if success: 52 | _LOGGER.debug(f"Copied {source_file} to {dest_file}") 53 | return True 54 | else: 55 | _LOGGER.warning(f"Source file not found: {source_file}") 56 | return False 57 | 58 | except Exception as e: 59 | _LOGGER.error(f"Failed to copy frontend files: {e}") 60 | return False 61 | 62 | async def init_resource(hass: HomeAssistant, url: str, ver: str) -> bool: 63 | """Add extra JS module for lovelace mode YAML and new lovelace resource 64 | for mode GUI. It's better to add extra JS for all modes, because it has 65 | random url to avoid problems with the cache. But chromecast don't support 66 | extra JS urls and can't load custom card. 67 | """ 68 | resources: ResourceStorageCollection = hass.data["lovelace"].resources 69 | # force load storage 70 | await resources.async_get_info() 71 | 72 | url2 = f"{url}?v={ver}" 73 | 74 | for item in resources.async_items(): 75 | if not item.get("url", "").startswith(url): 76 | continue 77 | 78 | # no need to update 79 | if item["url"].endswith(ver): 80 | return False 81 | 82 | _LOGGER.debug(f"Update lovelace resource to: {url2}") 83 | 84 | if isinstance(resources, ResourceStorageCollection): 85 | await resources.async_update_item( 86 | item["id"], {"res_type": "module", "url": url2} 87 | ) 88 | else: 89 | # not the best solution, but what else can we do 90 | item["url"] = url2 91 | 92 | return True 93 | 94 | if isinstance(resources, ResourceStorageCollection): 95 | _LOGGER.debug(f"Add new lovelace resource: {url2}") 96 | await resources.async_create_item({"res_type": "module", "url": url2}) 97 | else: 98 | _LOGGER.debug(f"Add extra JS module: {url2}") 99 | add_extra_js_url(hass, url2) 100 | 101 | return True 102 | 103 | # Configuration schema for YAML setup (required by hassfest) 104 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 105 | 106 | async def async_setup(hass: HomeAssistant, _: dict) -> bool: 107 | """Set up the integration by registering services and frontend resources.""" 108 | if hass.data.setdefault(DOMAIN, {}).get("services_registered"): 109 | return True 110 | 111 | # Copy frontend files from integration to www folder 112 | await copy_frontend_files(hass) 113 | 114 | # Option 1: Register static path pointing to www folder (after copy) 115 | await hass.http.async_register_static_paths([ 116 | StaticPathConfig( 117 | "/local/simple-timer/timer-card.js", 118 | hass.config.path("www/simple-timer/timer-card.js"), 119 | True 120 | ) 121 | ]) 122 | 123 | # Option 2: Alternative - serve directly from custom_components (uncomment to use) 124 | # integration_path = os.path.dirname(__file__) 125 | # await hass.http.async_register_static_paths([ 126 | # StaticPathConfig( 127 | # "/local/simple-timer/timer-card.js", 128 | # os.path.join(integration_path, "dist", "timer-card.js"), 129 | # True 130 | # ) 131 | # ]) 132 | 133 | # Initialize the frontend resource 134 | version = getattr(hass.data["integrations"][DOMAIN], "version", "1.0.0") 135 | await init_resource(hass, "/local/simple-timer/timer-card.js", str(version)) 136 | 137 | # Schema for the timer services 138 | SERVICE_START_TIMER_SCHEMA = vol.Schema({ 139 | vol.Required("entry_id"): cv.string, 140 | vol.Required("duration"): cv.positive_float, 141 | vol.Optional("unit", default="min"): vol.In(["s", "sec", "seconds", "m", "min", "minutes", "h", "hr", "hours", "d", "day", "days"]), 142 | vol.Optional("reverse_mode", default=False): cv.boolean, 143 | vol.Optional("start_method", default="button"): vol.In(["button", "slider"]), 144 | }) 145 | SERVICE_CANCEL_TIMER_SCHEMA = vol.Schema({ 146 | vol.Required("entry_id"): cv.string, 147 | }) 148 | # Schema for the service that tells the sensor which switch to monitor 149 | SERVICE_UPDATE_SWITCH_SCHEMA = vol.Schema({ 150 | vol.Required("entry_id"): cv.string, 151 | vol.Required("switch_entity_id"): cv.string, 152 | }) 153 | # Schema for manual name sync service 154 | SERVICE_FORCE_NAME_SYNC_SCHEMA = vol.Schema({ 155 | vol.Optional("entry_id"): cv.string, 156 | }) 157 | # Schema for manual power toggle service 158 | SERVICE_MANUAL_POWER_TOGGLE_SCHEMA = vol.Schema({ 159 | vol.Required("entry_id"): cv.string, 160 | vol.Required("action"): vol.In(["turn_on", "turn_off"]), 161 | }) 162 | # Schema for test notification service 163 | SERVICE_TEST_NOTIFICATION_SCHEMA = vol.Schema({ 164 | vol.Required("entry_id"): cv.string, 165 | vol.Optional("message", default="Test notification"): cv.string, 166 | }) 167 | SERVICE_RESET_DAILY_USAGE_SCHEMA = vol.Schema({ 168 | vol.Required("entry_id"): cv.string, 169 | }) 170 | SERVICE_RELOAD_RESOURCES_SCHEMA = vol.Schema({}) 171 | 172 | async def test_notification(call: ServiceCall): 173 | """Test notification functionality.""" 174 | entry_id = call.data["entry_id"] 175 | message = call.data.get("message", "Test notification") 176 | 177 | # Find the sensor by entry_id (hass is available from the outer scope here) 178 | sensor = None 179 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 180 | if stored_entry_id == entry_id and "sensor" in entry_data: 181 | sensor = entry_data["sensor"] 182 | break 183 | 184 | if sensor: 185 | await sensor._send_notification(message) 186 | else: 187 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 188 | 189 | async def start_timer(call: ServiceCall): 190 | """Handle the service call to start the device timer.""" 191 | entry_id = call.data["entry_id"] 192 | duration = call.data["duration"] 193 | unit = call.data.get("unit", "min") 194 | reverse_mode = call.data.get("reverse_mode", False) 195 | start_method = call.data.get("start_method", "button") 196 | 197 | # Find the sensor by entry_id 198 | sensor = None 199 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 200 | if stored_entry_id == entry_id and "sensor" in entry_data: 201 | sensor = entry_data["sensor"] 202 | break 203 | 204 | if sensor: 205 | await sensor.async_start_timer(duration, unit, reverse_mode, start_method) 206 | else: 207 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 208 | 209 | async def cancel_timer(call: ServiceCall): 210 | """Handle the service call to cancel the device timer.""" 211 | entry_id = call.data["entry_id"] 212 | 213 | # Find the sensor by entry_id 214 | sensor = None 215 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 216 | if stored_entry_id == entry_id and "sensor" in entry_data: 217 | sensor = entry_data["sensor"] 218 | break 219 | 220 | if sensor: 221 | await sensor.async_cancel_timer() 222 | else: 223 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 224 | 225 | async def update_switch_entity(call: ServiceCall): 226 | """Handle the service call to update the switch entity for the sensor.""" 227 | entry_id = call.data["entry_id"] 228 | switch_entity_id = call.data["switch_entity_id"] 229 | 230 | # Find the sensor by entry_id 231 | sensor = None 232 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 233 | if stored_entry_id == entry_id and "sensor" in entry_data: 234 | sensor = entry_data["sensor"] 235 | break 236 | 237 | if sensor: 238 | await sensor.async_update_switch_entity(switch_entity_id) 239 | else: 240 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 241 | 242 | async def force_name_sync(call: ServiceCall): 243 | """Handle the service call to force immediate name synchronization.""" 244 | entry_id = call.data.get("entry_id") 245 | 246 | if entry_id: 247 | # Sync specific entry 248 | if entry_id in hass.data[DOMAIN] and "sensor" in hass.data[DOMAIN][entry_id]: 249 | sensor = hass.data[DOMAIN][entry_id]["sensor"] 250 | if sensor: 251 | result = await sensor.async_force_name_sync() 252 | if result: 253 | return 254 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 255 | else: 256 | # Sync all entries 257 | synced_count = 0 258 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 259 | if "sensor" in entry_data and entry_data["sensor"]: 260 | try: 261 | await entry_data["sensor"].async_force_name_sync() 262 | synced_count += 1 263 | except Exception as e: 264 | # Log error but continue with other sensors 265 | pass 266 | 267 | if synced_count > 0: 268 | # Could add notification here if desired 269 | pass 270 | 271 | async def manual_power_toggle(call: ServiceCall): 272 | """Handle manual power toggle from frontend card.""" 273 | entry_id = call.data["entry_id"] 274 | action = call.data["action"] 275 | 276 | # Find the sensor by entry_id 277 | sensor = None 278 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 279 | if stored_entry_id == entry_id and "sensor" in entry_data: 280 | sensor = entry_data["sensor"] 281 | break 282 | 283 | if sensor: 284 | await sensor.async_manual_power_toggle(action) 285 | else: 286 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 287 | 288 | async def reset_daily_usage(call: ServiceCall): 289 | """Handle manual daily usage reset.""" 290 | entry_id = call.data["entry_id"] 291 | 292 | # Find the sensor by entry_id 293 | sensor = None 294 | for stored_entry_id, entry_data in hass.data[DOMAIN].items(): 295 | if stored_entry_id == entry_id and "sensor" in entry_data: 296 | sensor = entry_data["sensor"] 297 | break 298 | 299 | if sensor: 300 | await sensor.async_reset_daily_usage() 301 | else: 302 | raise ValueError(f"No simple timer sensor found for entry_id: {entry_id}") 303 | 304 | async def reload_resources(call: ServiceCall): 305 | """Reload frontend resources with current manifest version.""" 306 | try: 307 | _LOGGER.info("Simple Timer: Reloading resources") 308 | 309 | # Copy updated files 310 | await copy_frontend_files(hass) 311 | 312 | # Read version from manifest using async executor to avoid blocking 313 | def read_manifest(): 314 | manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json") 315 | with open(manifest_path, 'r') as f: 316 | manifest = json.load(f) 317 | return manifest.get('version', '1.0.0') 318 | 319 | version = await hass.async_add_executor_job(read_manifest) 320 | 321 | # Re-register resource with new version 322 | await init_resource(hass, "/local/simple-timer/timer-card.js", version) 323 | 324 | _LOGGER.info(f"Simple Timer: Resources updated to version {version}") 325 | 326 | # Send notification 327 | await hass.services.async_call( 328 | "persistent_notification", 329 | "create", 330 | { 331 | "message": f"Simple Timer resources reloaded with version {version}. Please refresh your browser (Ctrl+Shift+R).", 332 | "title": "Simple Timer Resources Updated", 333 | "notification_id": "simple_timer_resource_reload" 334 | } 335 | ) 336 | 337 | except Exception as e: 338 | _LOGGER.error(f"Simple Timer: Resource reload failed: {e}") 339 | raise 340 | 341 | # Register all services 342 | hass.services.async_register( 343 | DOMAIN, "start_timer", start_timer, schema=SERVICE_START_TIMER_SCHEMA 344 | ) 345 | hass.services.async_register( 346 | DOMAIN, "cancel_timer", cancel_timer, schema=SERVICE_CANCEL_TIMER_SCHEMA 347 | ) 348 | hass.services.async_register( 349 | DOMAIN, "update_switch_entity", update_switch_entity, schema=SERVICE_UPDATE_SWITCH_SCHEMA 350 | ) 351 | hass.services.async_register( 352 | DOMAIN, "force_name_sync", force_name_sync, schema=SERVICE_FORCE_NAME_SYNC_SCHEMA 353 | ) 354 | hass.services.async_register( 355 | DOMAIN, "manual_power_toggle", manual_power_toggle, schema=SERVICE_MANUAL_POWER_TOGGLE_SCHEMA 356 | ) 357 | hass.services.async_register( 358 | DOMAIN, "test_notification", test_notification, schema=SERVICE_TEST_NOTIFICATION_SCHEMA 359 | ) 360 | hass.services.async_register( 361 | DOMAIN, "reset_daily_usage", reset_daily_usage, schema=SERVICE_RESET_DAILY_USAGE_SCHEMA 362 | ) 363 | hass.services.async_register( 364 | DOMAIN, "reload_resources", reload_resources, schema=vol.Schema({}) 365 | ) 366 | 367 | hass.data[DOMAIN]["services_registered"] = True 368 | return True 369 | 370 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 371 | """Set up a single Simple Timer config entry.""" 372 | hass.data[DOMAIN][entry.entry_id] = {"sensor": None} # Initialize with None 373 | 374 | # Add update listener to block title-only changes (3-dots rename) 375 | entry.add_update_listener(_async_update_listener) 376 | 377 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 378 | return True 379 | 380 | async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 381 | """Handle config entry updates and block unwanted renames.""" 382 | # This listener intentionally does minimal work 383 | # The real update handling is done in the sensor's _handle_config_entry_update method 384 | # This listener is mainly here to ensure the sensor gets notified of changes 385 | pass 386 | 387 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 388 | """Unload a Simple Timer config entry.""" 389 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 390 | if unload_ok: 391 | hass.data[DOMAIN].pop(entry.entry_id, None) 392 | return unload_ok -------------------------------------------------------------------------------- /custom_components/simple_timer/config_flow.py: -------------------------------------------------------------------------------- 1 | # config_flow.py 2 | """Config flow for Simple Timer.""" 3 | import voluptuous as vol 4 | import logging 5 | from datetime import time 6 | 7 | from homeassistant import config_entries 8 | from homeassistant.core import HomeAssistant, callback 9 | import homeassistant.helpers.config_validation as cv 10 | from homeassistant.helpers import selector 11 | from .const import DOMAIN 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | def _validate_time_string(time_str: str) -> bool: 16 | """Validate time string format (HH:MM).""" 17 | try: 18 | time.fromisoformat(time_str + ":00") 19 | return True 20 | except ValueError: 21 | return False 22 | 23 | class SimpleTimerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 24 | """Handle a config flow for Simple Timer.""" 25 | VERSION = 1 26 | 27 | def __init__(self): 28 | """Initialize the config flow.""" 29 | self._switch_entity_id = None 30 | self._notification_entities = [] 31 | 32 | def _get_notification_services(self): 33 | """Get available notification services with comprehensive discovery.""" 34 | if not self.hass or not hasattr(self.hass, 'services'): 35 | return [] 36 | 37 | services = [] 38 | 39 | try: 40 | # Method 1: Get all notify.* services using service registry 41 | notify_services = self.hass.services.async_services().get("notify", {}) 42 | for service_name in notify_services.keys(): 43 | if service_name not in ["send", "persistent_notification"]: # Exclude base services 44 | services.append(f"notify.{service_name}") 45 | 46 | # Method 2: Get notification services from other domains 47 | all_services = self.hass.services.async_services() 48 | for domain, domain_services in all_services.items(): 49 | if domain != "notify": 50 | for service_name in domain_services.keys(): 51 | # Look for notification-related services 52 | if (any(keyword in service_name.lower() for keyword in ["send", "message", "notify"]) or 53 | any(keyword in domain.lower() for keyword in ["telegram", "mobile_app", "discord", "slack", "pushbullet", "pushover"])): 54 | full_service = f"{domain}.{service_name}" 55 | if full_service not in services: 56 | services.append(full_service) 57 | 58 | # Method 3: Check for common notification integrations by entity registry 59 | try: 60 | from homeassistant.helpers import entity_registry as er 61 | entity_registry = er.async_get(self.hass) 62 | if entity_registry: 63 | # Look for mobile app entities and infer services 64 | for entity in entity_registry.entities.values(): 65 | if entity.platform == "mobile_app" and entity.domain == "notify": 66 | service_name = f"notify.mobile_app_{entity.unique_id.split('_')[0]}" 67 | if service_name not in services: 68 | services.append(service_name) 69 | except Exception: 70 | pass # Don't fail if entity registry access fails 71 | 72 | except Exception as e: 73 | _LOGGER.error(f"Simple Timer: Error getting notification services: {e}") 74 | return [] 75 | 76 | # Remove duplicates and sort 77 | services = list(set(services)) 78 | services.sort() 79 | 80 | _LOGGER.debug(f"Simple Timer: Found {len(services)} notification services: {services}") 81 | return services 82 | 83 | async def async_step_user(self, user_input=None): 84 | """ 85 | First step: Select the switch entity. 86 | """ 87 | errors = {} 88 | 89 | _LOGGER.info(f"Simple Timer: Starting config flow step 'user'") 90 | 91 | if user_input is not None: 92 | try: 93 | # EntitySelector returns the entity_id directly as a string 94 | switch_entity_id = user_input.get("switch_entity_id") 95 | 96 | _LOGGER.debug(f"Simple Timer: config_flow: switch_entity_id = {switch_entity_id}") 97 | 98 | # Validate switch entity 99 | if not switch_entity_id: 100 | errors["switch_entity_id"] = "Please select an entity" 101 | elif not isinstance(switch_entity_id, str): 102 | errors["switch_entity_id"] = "Invalid entity format" 103 | else: 104 | # Check if entity exists 105 | entity_state = self.hass.states.get(switch_entity_id) 106 | if entity_state is None: 107 | errors["switch_entity_id"] = "Entity not found" 108 | else: 109 | # Store the selected entity and move to name step 110 | self._switch_entity_id = switch_entity_id 111 | return await self.async_step_name() 112 | 113 | except Exception as e: 114 | _LOGGER.error(f"Simple Timer: config_flow: Exception in step_user: {e}") 115 | errors["base"] = "An error occurred. Please try again." 116 | 117 | # Check if we have any compatible entities 118 | compatible_entities_exist = False 119 | if self.hass: 120 | SWITCH_LIKE_DOMAINS = ["switch", "input_boolean", "light", "fan"] 121 | for domain in SWITCH_LIKE_DOMAINS: 122 | try: 123 | domain_entities = self.hass.states.async_entity_ids(domain) 124 | if domain_entities: 125 | compatible_entities_exist = True 126 | break 127 | except Exception as e: 128 | _LOGGER.warning(f"Simple Timer: config_flow: Error checking domain {domain}: {e}") 129 | 130 | if not compatible_entities_exist: 131 | errors["base"] = "No controllable entities found" 132 | 133 | # Show entity selector 134 | data_schema = vol.Schema({ 135 | vol.Required("switch_entity_id"): selector.EntitySelector( 136 | selector.EntitySelectorConfig( 137 | domain=["switch", "input_boolean", "light", "fan"] 138 | ) 139 | ), 140 | }) 141 | 142 | _LOGGER.info(f"Simple Timer: Showing form for step 'user'") 143 | return self.async_show_form( 144 | step_id="user", 145 | data_schema=data_schema, 146 | errors=errors, 147 | description_placeholders={}, 148 | last_step=False # This tells HA there are more steps coming 149 | ) 150 | 151 | async def async_step_name(self, user_input=None): 152 | """ 153 | Second step: Set the name and other configuration options. 154 | """ 155 | errors = {} 156 | 157 | _LOGGER.info(f"Simple Timer: Starting config flow step 'name'") 158 | 159 | if user_input is not None: 160 | try: 161 | name = user_input.get("name", "").strip() 162 | show_seconds = user_input.get("show_seconds", False) 163 | selected_notifications = user_input.get("Select one or more notification entity (optional):", []) 164 | reset_time_str = user_input.get("reset_time", "00:00") 165 | 166 | # Validate reset time 167 | if not _validate_time_string(reset_time_str): 168 | errors["reset_time"] = "Invalid time format. Use HH:MM (24-hour format)" 169 | else: 170 | # Update notification list from multi-select (handles both add and remove) 171 | self._notification_entities = selected_notifications if selected_notifications else [] 172 | _LOGGER.info(f"Simple Timer: Updated notifications to: {self._notification_entities}") 173 | 174 | # FINAL SUBMIT logic: Save everything 175 | if not name: 176 | errors["name"] = "Please enter a name" 177 | else: 178 | _LOGGER.info(f"Simple Timer: FINAL SUBMIT - Creating entry with notifications={self._notification_entities}, reset_time={reset_time_str}") 179 | return self.async_create_entry( 180 | title=name, 181 | data={ 182 | "name": name, 183 | "switch_entity_id": self._switch_entity_id, 184 | "notification_entities": self._notification_entities, 185 | "show_seconds": show_seconds, 186 | "reset_time": reset_time_str 187 | } 188 | ) 189 | 190 | except Exception as e: 191 | _LOGGER.error(f"Simple Timer: config_flow: Exception in step_name: {e}") 192 | errors["base"] = "An error occurred. Please try again." 193 | 194 | # Auto-generate name from the selected entity 195 | suggested_name = "" 196 | if self._switch_entity_id: 197 | entity_state = self.hass.states.get(self._switch_entity_id) 198 | if entity_state: 199 | # Try to get friendly name first, then fall back to entity_id 200 | friendly_name = entity_state.attributes.get("friendly_name") 201 | if friendly_name: 202 | suggested_name = friendly_name 203 | else: 204 | # Fall back to entity_id based name 205 | suggested_name = self._switch_entity_id.split(".")[-1].replace("_", " ").title() 206 | 207 | # Get available notification services 208 | available_notifications = self._get_notification_services() 209 | 210 | # Build form schema 211 | schema_dict = { 212 | vol.Required("name", default=suggested_name): str, 213 | } 214 | 215 | # Add single multi-select dropdown for all notification management 216 | if available_notifications: 217 | notification_options = [] 218 | for service in available_notifications: 219 | notification_options.append({"value": service, "label": service}) 220 | 221 | schema_dict[vol.Optional("Select one or more notification entity (optional):", default=self._notification_entities)] = selector.SelectSelector( 222 | selector.SelectSelectorConfig( 223 | options=notification_options, 224 | multiple=True, # Multi-select for both add and remove 225 | mode=selector.SelectSelectorMode.DROPDOWN 226 | ) 227 | ) 228 | 229 | # Add reset time configuration 230 | schema_dict[vol.Optional("reset_time", default="00:00")] = selector.TextSelector( 231 | selector.TextSelectorConfig( 232 | type=selector.TextSelectorType.TIME 233 | ) 234 | ) 235 | 236 | # Add show_seconds at the bottom 237 | schema_dict[vol.Optional("show_seconds", default=False)] = bool 238 | 239 | data_schema = vol.Schema(schema_dict) 240 | 241 | # Create description with current notifications and reset time info 242 | description_placeholders = { 243 | "selected_entity": self._switch_entity_id, 244 | "entity_name": suggested_name 245 | } 246 | 247 | if self._notification_entities: 248 | description_placeholders["current_notifications"] = ", ".join(self._notification_entities) 249 | else: 250 | description_placeholders["current_notifications"] = "None selected" 251 | 252 | _LOGGER.info(f"Simple Timer: Showing form for step 'name' with {len(self._notification_entities)} notifications") 253 | return self.async_show_form( 254 | step_id="name", 255 | data_schema=data_schema, 256 | errors=errors, 257 | description_placeholders=description_placeholders 258 | ) 259 | 260 | async def async_step_init(self, user_input=None): 261 | """Handle a flow initiated by the user.""" 262 | return await self.async_step_user(user_input) 263 | 264 | @staticmethod 265 | @callback 266 | def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow: 267 | """Get the options flow for this handler.""" 268 | return SimpleTimerOptionsFlow(config_entry) 269 | 270 | 271 | class SimpleTimerOptionsFlow(config_entries.OptionsFlow): 272 | """Handle options flow for Simple Timer.""" 273 | 274 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 275 | """Initialize options flow.""" 276 | self._notification_entities = list(config_entry.data.get("notification_entities", [])) 277 | 278 | def _get_notification_services(self): 279 | """Get available notification services with comprehensive discovery.""" 280 | if not self.hass or not hasattr(self.hass, 'services'): 281 | return [] 282 | 283 | services = [] 284 | 285 | try: 286 | # Method 1: Get all notify.* services using service registry 287 | notify_services = self.hass.services.async_services().get("notify", {}) 288 | for service_name in notify_services.keys(): 289 | if service_name not in ["send", "persistent_notification"]: # Exclude base services 290 | services.append(f"notify.{service_name}") 291 | 292 | # Method 2: Get notification services from other domains 293 | all_services = self.hass.services.async_services() 294 | for domain, domain_services in all_services.items(): 295 | if domain != "notify": 296 | for service_name in domain_services.keys(): 297 | # Look for notification-related services 298 | if (any(keyword in service_name.lower() for keyword in ["send", "message", "notify"]) or 299 | any(keyword in domain.lower() for keyword in ["telegram", "mobile_app", "discord", "slack", "pushbullet", "pushover"])): 300 | full_service = f"{domain}.{service_name}" 301 | if full_service not in services: 302 | services.append(full_service) 303 | 304 | # Method 3: Check for common notification integrations by entity registry 305 | try: 306 | from homeassistant.helpers import entity_registry as er 307 | entity_registry = er.async_get(self.hass) 308 | if entity_registry: 309 | # Look for mobile app entities and infer services 310 | for entity in entity_registry.entities.values(): 311 | if entity.platform == "mobile_app" and entity.domain == "notify": 312 | service_name = f"notify.mobile_app_{entity.unique_id.split('_')[0]}" 313 | if service_name not in services: 314 | services.append(service_name) 315 | except Exception: 316 | pass # Don't fail if entity registry access fails 317 | 318 | except Exception as e: 319 | _LOGGER.error(f"Simple Timer: Error getting notification services: {e}") 320 | return [] 321 | 322 | # Remove duplicates and sort 323 | services = list(set(services)) 324 | services.sort() 325 | 326 | _LOGGER.debug(f"Simple Timer: Found {len(services)} notification services: {services}") 327 | return services 328 | 329 | async def async_step_init(self, user_input=None): 330 | """Manage the options.""" 331 | errors = {} 332 | 333 | # Force sync when options flow opens 334 | await self._force_name_sync_on_open() 335 | 336 | if user_input is not None: 337 | try: 338 | name = user_input.get("name", "").strip() 339 | switch_entity_id = user_input.get("switch_entity_id") 340 | show_seconds = user_input.get("show_seconds", False) 341 | selected_notifications = user_input.get("Select one or more notification entity (optional):", []) 342 | reset_time_str = user_input.get("reset_time", "00:00") 343 | 344 | # Validate reset time 345 | if not _validate_time_string(reset_time_str): 346 | errors["reset_time"] = "Invalid time format. Use HH:MM (24-hour format)" 347 | else: 348 | # Update notification list from multi-select (handles both add and remove) 349 | self._notification_entities = selected_notifications if selected_notifications else [] 350 | _LOGGER.info(f"Simple Timer: Updated notifications to: {self._notification_entities}") 351 | 352 | # FINAL SUBMIT logic: Save everything 353 | if not name: 354 | errors["name"] = "Please enter a name" 355 | elif not switch_entity_id: 356 | errors["switch_entity_id"] = "Please select an entity" 357 | else: 358 | # Check if entity exists 359 | entity_state = self.hass.states.get(switch_entity_id) 360 | if entity_state is None: 361 | errors["switch_entity_id"] = "Entity not found" 362 | else: 363 | _LOGGER.info(f"Simple Timer: FINAL SUBMIT - Saving with notifications={self._notification_entities}, reset_time={reset_time_str}") 364 | await self._update_config_entry(name, switch_entity_id, show_seconds, reset_time_str) 365 | return self.async_create_entry(title="", data={}) 366 | 367 | except Exception as e: 368 | _LOGGER.error(f"Simple Timer: options_flow: Exception: {e}") 369 | errors["base"] = "An error occurred. Please try again." 370 | 371 | # Get current values 372 | current_name = self.config_entry.data.get("name") or self.config_entry.title or "Timer" 373 | current_switch_entity = self.config_entry.data.get("switch_entity_id", "") 374 | current_show_seconds = self.config_entry.data.get("show_seconds", False) 375 | current_reset_time = self.config_entry.data.get("reset_time", "00:00") 376 | 377 | # Validate current switch entity 378 | current_switch_exists = True 379 | if current_switch_entity: 380 | entity_state = self.hass.states.get(current_switch_entity) 381 | if entity_state is None: 382 | current_switch_exists = False 383 | errors["switch_entity_id"] = f"Current entity '{current_switch_entity}' not found. Please select a new one." 384 | 385 | # Get available notification services 386 | available_notifications = self._get_notification_services() 387 | 388 | # Build form schema 389 | schema_dict = { 390 | vol.Required("name", default=current_name): str, 391 | vol.Required("switch_entity_id", default=current_switch_entity if current_switch_exists else ""): selector.EntitySelector( 392 | selector.EntitySelectorConfig( 393 | domain=["switch", "input_boolean", "light", "fan"] 394 | ) 395 | ), 396 | } 397 | 398 | # Add single multi-select dropdown for all notification management 399 | if available_notifications: 400 | notification_options = [] 401 | for service in available_notifications: 402 | notification_options.append({"value": service, "label": service}) 403 | 404 | schema_dict[vol.Optional("Select one or more notification entity (optional):", default=self._notification_entities)] = selector.SelectSelector( 405 | selector.SelectSelectorConfig( 406 | options=notification_options, 407 | multiple=True, # Multi-select for both add and remove 408 | mode=selector.SelectSelectorMode.DROPDOWN 409 | ) 410 | ) 411 | 412 | # Add reset time configuration 413 | schema_dict[vol.Optional("reset_time", default=current_reset_time)] = selector.TextSelector( 414 | selector.TextSelectorConfig( 415 | type=selector.TextSelectorType.TIME 416 | ) 417 | ) 418 | 419 | # Add show_seconds at the bottom 420 | schema_dict[vol.Optional("show_seconds", default=current_show_seconds)] = bool 421 | 422 | data_schema = vol.Schema(schema_dict) 423 | 424 | # Add migration notice if old card settings might exist 425 | description_placeholders = {} 426 | if self._notification_entities: 427 | description_placeholders["current_notifications"] = ", ".join(self._notification_entities) 428 | else: 429 | description_placeholders["current_notifications"] = "None selected" 430 | description_placeholders["migration_notice"] = "Note: Notification and display settings have been moved from individual cards to the integration configuration. Please configure them here." 431 | 432 | return self.async_show_form( 433 | step_id="init", 434 | data_schema=data_schema, 435 | errors=errors, 436 | description_placeholders=description_placeholders 437 | ) 438 | 439 | async def _force_name_sync_on_open(self): 440 | """Force name sync when options flow opens.""" 441 | current_title = self.config_entry.title 442 | current_data_name = self.config_entry.data.get("name") 443 | 444 | _LOGGER.info(f"Simple Timer: Options flow opened - title: '{current_title}', data_name: '{current_data_name}'") 445 | 446 | # If they differ, sync them 447 | if current_title and current_data_name != current_title: 448 | _LOGGER.info(f"Simple Timer: FORCE SYNCING '{current_title}' to entry.data['name']") 449 | 450 | # Update entry data 451 | new_data = dict(self.config_entry.data) 452 | new_data["name"] = current_title 453 | 454 | self.hass.config_entries.async_update_entry( 455 | self.config_entry, 456 | data=new_data 457 | ) 458 | 459 | async def _update_config_entry(self, name: str, switch_entity_id: str, show_seconds: bool, reset_time: str): 460 | """Update config entry and force immediate sensor sync.""" 461 | new_data = { 462 | "name": name, 463 | "switch_entity_id": switch_entity_id, 464 | "notification_entities": self._notification_entities, 465 | "show_seconds": show_seconds, 466 | "reset_time": reset_time 467 | } 468 | 469 | _LOGGER.info(f"Simple Timer: Updating entry {self.config_entry.entry_id} with name='{name}', switch='{switch_entity_id}', notifications={self._notification_entities}, show_seconds={show_seconds}, reset_time={reset_time}") 470 | 471 | # Update both data and title 472 | self.hass.config_entries.async_update_entry( 473 | self.config_entry, 474 | data=new_data, 475 | title=name 476 | ) 477 | 478 | # Force immediate sensor update 479 | await self._force_sensor_update() 480 | 481 | async def _force_sensor_update(self): 482 | """Force immediate sensor update with multiple methods.""" 483 | try: 484 | if DOMAIN in self.hass.data and self.config_entry.entry_id in self.hass.data[DOMAIN]: 485 | sensor_data = self.hass.data[DOMAIN][self.config_entry.entry_id] 486 | if "sensor" in sensor_data and sensor_data["sensor"]: 487 | sensor = sensor_data["sensor"] 488 | 489 | # Method 1: Update tracking variables 490 | sensor._last_known_title = self.config_entry.title 491 | sensor._last_known_data_name = self.config_entry.data.get("name") 492 | 493 | # Method 2: Force name change handler 494 | await sensor._handle_name_change() 495 | 496 | # Method 3: Force reset time update 497 | await sensor._update_reset_time() 498 | 499 | # Method 4: Force state write 500 | sensor.async_write_ha_state() 501 | 502 | # Method 5: Force entity registry update 503 | from homeassistant.helpers import entity_registry as er 504 | entity_registry = er.async_get(self.hass) 505 | if entity_registry: 506 | entity_registry.async_update_entity( 507 | sensor.entity_id, 508 | name=sensor.name 509 | ) 510 | 511 | _LOGGER.info(f"Simple Timer: FORCED complete sensor update - new name: '{sensor.name}', reset_time: '{reset_time}'") 512 | else: 513 | _LOGGER.warning(f"Simple Timer: Sensor not found in hass.data for entry {self.config_entry.entry_id}") 514 | except Exception as e: 515 | _LOGGER.error(f"Simple Timer: Failed to force sensor update: {e}") -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /src/timer-card-editor.ts: -------------------------------------------------------------------------------- 1 | // timer-card-editor.ts 2 | 3 | import { LitElement, html } from 'lit'; 4 | import { editorCardStyles } from './timer-card-editor.styles'; 5 | 6 | // Note: TimerCardConfig interface is defined in global.d.ts 7 | 8 | interface HAState { 9 | entity_id: string; 10 | state: string; 11 | attributes: { 12 | friendly_name?: string; 13 | entry_id?: string; 14 | switch_entity_id?: string; 15 | instance_title?: string; 16 | [key: string]: any; 17 | }; 18 | last_changed: string; 19 | last_updated: string; 20 | context: { 21 | id: string; 22 | parent_id: string | null; 23 | user_id: string | null; 24 | }; 25 | } 26 | 27 | interface HAService { 28 | description: string; 29 | fields: { 30 | [field: string]: { 31 | description: string; 32 | example: string; 33 | }; 34 | }; 35 | } 36 | 37 | interface HomeAssistant { 38 | states: { 39 | [entityId: string]: HAState; 40 | }; 41 | services: { 42 | notify?: { [service: string]: HAService }; 43 | switch?: { [service: string]: HAService }; 44 | [domain: string]: { [service: string]: HAService } | undefined; 45 | }; 46 | callService(domain: string, service: string, data?: Record): Promise; 47 | callApi(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, parameters?: Record, headers?: Record): Promise; 48 | config: { 49 | components: { 50 | [domain: string]: { 51 | config_entries: { [entry_id: string]: unknown }; 52 | }; 53 | }; 54 | [key: string]: any; 55 | }; 56 | } 57 | 58 | interface HAConfigEntry { 59 | entry_id: string; 60 | title: string; 61 | domain: string; 62 | } 63 | 64 | interface HAConfigEntriesByDomainResponse { 65 | entry_by_domain: { 66 | [domain: string]: HAConfigEntry[]; 67 | }; 68 | } 69 | 70 | const ATTR_INSTANCE_TITLE = "instance_title"; 71 | const DOMAIN = "simple_timer"; 72 | const DEFAULT_TIMER_BUTTONS = [15, 30, 60, 90, 120, 150]; // Default for new cards only 73 | 74 | class TimerCardEditor extends LitElement { 75 | static properties = { 76 | hass: { type: Object }, 77 | _config: { type: Object }, 78 | _newTimerButtonValue: { type: String }, 79 | }; 80 | 81 | hass?: HomeAssistant; 82 | _config: TimerCardConfig; 83 | _configFullyLoaded: boolean = false; // Track if we've received a complete config 84 | 85 | private _timerInstancesOptions: Array<{ value: string; label: string }> = []; 86 | private _tempSliderMaxValue: string | null = null; 87 | private _newTimerButtonValue: string = ""; 88 | 89 | constructor() { 90 | super(); 91 | this._config = { 92 | type: "custom:timer-card", 93 | timer_buttons: [...DEFAULT_TIMER_BUTTONS], // Use centralized default 94 | timer_instance_id: null, 95 | card_title: null 96 | }; 97 | } 98 | 99 | private _getComputedCSSVariable(variableName: string, fallback: string = "#000000"): string { 100 | try { 101 | // Get the computed style from the document root or this element 102 | const computedStyle = getComputedStyle(document.documentElement); 103 | const value = computedStyle.getPropertyValue(variableName).trim(); 104 | 105 | // If we got a value and it's a valid color, return it 106 | if (value && value !== '') { 107 | // Handle both hex colors and rgb/rgba 108 | return value; 109 | } 110 | } catch (e) { 111 | console.warn(`Failed to get CSS variable ${variableName}:`, e); 112 | } 113 | 114 | return fallback; 115 | } 116 | 117 | private _rgbToHex(rgb: string): string { 118 | // Handle rgb(r, g, b) or rgba(r, g, b, a) 119 | const match = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/); 120 | if (match) { 121 | const r = parseInt(match[1]); 122 | const g = parseInt(match[2]); 123 | const b = parseInt(match[3]); 124 | return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 125 | } 126 | return rgb; // Return as-is if already hex or invalid 127 | } 128 | 129 | private _getThemeColorHex(variableName: string, fallback: string = "#000000"): string { 130 | const value = this._getComputedCSSVariable(variableName, fallback); 131 | 132 | // If it's already a hex color, return it 133 | if (value.startsWith('#')) { 134 | return value; 135 | } 136 | 137 | // If it's rgb/rgba, convert to hex 138 | if (value.startsWith('rgb')) { 139 | return this._rgbToHex(value); 140 | } 141 | 142 | return fallback; 143 | } 144 | 145 | async _getSimpleTimerInstances(): Promise> { 146 | if (!this.hass || !this.hass.states) { 147 | console.warn("TimerCardEditor: hass.states not available when trying to fetch instances from states."); 148 | return []; 149 | } 150 | 151 | const instancesMap = new Map(); 152 | 153 | for (const entityId in this.hass.states) { 154 | const state = this.hass.states[entityId]; 155 | 156 | // Look for sensors that have the required simple timer attributes 157 | // The entity name format is now: "[Instance Name] Runtime ([entry_id_short])" 158 | if (entityId.startsWith('sensor.') && 159 | entityId.includes('runtime') && // Runtime sensors contain 'runtime' in their ID 160 | state.attributes.entry_id && 161 | typeof state.attributes.entry_id === 'string' && 162 | state.attributes.switch_entity_id && 163 | typeof state.attributes.switch_entity_id === 'string' 164 | ) { 165 | const entryId = state.attributes.entry_id; 166 | const instanceTitle = state.attributes[ATTR_INSTANCE_TITLE]; 167 | 168 | let instanceLabel = `Timer Control (${entryId.substring(0, 8)})`; 169 | 170 | console.debug(`TimerCardEditor: Processing sensor ${entityId} (Entry: ${entryId})`); 171 | console.debug(`TimerCardEditor: Found raw attribute '${ATTR_INSTANCE_TITLE}': ${instanceTitle}`); 172 | console.debug(`TimerCardEditor: Type of raw attribute: ${typeof instanceTitle}`); 173 | 174 | if (instanceTitle && typeof instanceTitle === 'string' && instanceTitle.trim() !== '') { 175 | instanceLabel = instanceTitle.trim(); 176 | console.debug(`TimerCardEditor: Using '${ATTR_INSTANCE_TITLE}' for label: "${instanceLabel}"`); 177 | } else { 178 | console.warn(`TimerCardEditor: Sensor '${entityId}' has no valid '${ATTR_INSTANCE_TITLE}' attribute. Falling back to entry ID based label: "${instanceLabel}".`); 179 | } 180 | 181 | if (!instancesMap.has(entryId)) { 182 | instancesMap.set(entryId, { value: entryId, label: instanceLabel }); 183 | console.debug(`TimerCardEditor: Added instance: ${instanceLabel} (${entryId}) from sensor: ${entityId}`); 184 | } else { 185 | console.debug(`TimerCardEditor: Skipping duplicate entry_id: ${entryId}`); 186 | } 187 | } 188 | } 189 | 190 | const instances = Array.from(instancesMap.values()); 191 | instances.sort((a, b) => a.label.localeCompare(b.label)); 192 | 193 | if (instances.length === 0) { 194 | console.info(`TimerCardEditor: No Simple Timer integration instances found by scanning hass.states.`); 195 | } 196 | 197 | return instances; 198 | } 199 | 200 | _getValidatedTimerButtons(configButtons: any): (number | string)[] { 201 | if (Array.isArray(configButtons)) { 202 | const validatedButtons: (number | string)[] = []; 203 | const seen = new Set(); 204 | 205 | configButtons.forEach(val => { 206 | const strVal = String(val).trim().toLowerCase(); 207 | // Allow pure numbers (including decimals) or numbers with unit suffix 208 | const match = strVal.match(/^(\d+(?:\.\d+)?)\s*(s|sec|seconds|m|min|minutes|h|hr|hours|d|day|days)?$/); 209 | 210 | if (match) { 211 | const numVal = parseFloat(match[1]); 212 | const isFloat = match[1].includes('.'); 213 | const unitStr = match[2] || 'min'; 214 | const isHours = unitStr && (unitStr.startsWith('h') || ['h', 'hr', 'hours'].includes(unitStr)); 215 | const isDays = unitStr && (unitStr.startsWith('d') || ['d', 'day', 'days'].includes(unitStr)); 216 | 217 | // User Restriction: Fractional numbers only allowed for hours and days 218 | if (isFloat && !isHours && !isDays) { 219 | // Skip this value, it's invalid per rule 220 | return; 221 | } 222 | 223 | // User Restriction: Max 1 digit after decimal for hours and days 224 | if (isFloat && (isHours || isDays)) { 225 | const decimalPart = match[1].split('.')[1]; 226 | if (decimalPart && decimalPart.length > 1) { 227 | return; 228 | } 229 | } 230 | 231 | // User Restriction: Limit to 9999 for all units 232 | if (numVal > 9999) { 233 | return; 234 | } 235 | 236 | // Normalize pure numbers to number type for existing logic compatibility 237 | if (!unitStr || ['m', 'min', 'minutes'].includes(unitStr)) { 238 | if (numVal > 0 && numVal <= 9999) { 239 | if (!seen.has(String(numVal))) { 240 | validatedButtons.push(numVal); 241 | seen.add(String(numVal)); 242 | } 243 | } 244 | } else { 245 | // Keep strings with other units 246 | if (!seen.has(strVal)) { 247 | validatedButtons.push(val); // Keep original casing/format or normalize? prefer original if valid 248 | seen.add(strVal); 249 | } 250 | } 251 | } 252 | }); 253 | 254 | // Sort: numbers first (sorted), then strings (alphabetical or just appended) 255 | // Actually standard logic sorts numbers. 256 | const numbers = validatedButtons.filter(b => typeof b === 'number') as number[]; 257 | const strings = validatedButtons.filter(b => typeof b === 'string') as string[]; 258 | 259 | numbers.sort((a, b) => a - b); 260 | strings.sort(); 261 | 262 | return [...numbers, ...strings]; 263 | } 264 | 265 | if (configButtons === undefined || configButtons === null) { 266 | console.log(`TimerCardEditor: No timer_buttons in config, using empty array.`); 267 | return []; 268 | } 269 | 270 | console.warn(`TimerCardEditor: Invalid timer_buttons type (${typeof configButtons}):`, configButtons, `- using empty array`); 271 | return []; 272 | } 273 | 274 | async setConfig(cfg: TimerCardConfig): Promise { 275 | const oldConfig = { ...this._config }; 276 | 277 | const timerButtonsToSet = this._getValidatedTimerButtons(cfg.timer_buttons); 278 | 279 | const newConfigData: TimerCardConfig = { 280 | type: cfg.type || "custom:timer-card", 281 | timer_buttons: timerButtonsToSet, 282 | card_title: cfg.card_title || null, 283 | power_button_icon: cfg.power_button_icon || null, 284 | slider_max: cfg.slider_max || 120, 285 | slider_unit: cfg.slider_unit || 'min', 286 | reverse_mode: cfg.reverse_mode || false, 287 | hide_slider: cfg.hide_slider || false, 288 | show_daily_usage: cfg.show_daily_usage !== false, 289 | slider_thumb_color: cfg.slider_thumb_color || null, 290 | slider_background_color: cfg.slider_background_color || null, 291 | timer_button_font_color: cfg.timer_button_font_color || null, 292 | timer_button_background_color: cfg.timer_button_background_color || null, 293 | power_button_background_color: cfg.power_button_background_color || null, 294 | power_button_icon_color: cfg.power_button_icon_color || null 295 | }; 296 | 297 | if (cfg.timer_instance_id) { 298 | newConfigData.timer_instance_id = cfg.timer_instance_id; 299 | } else { 300 | console.info(`TimerCardEditor: setConfig - no timer_instance_id in config, will remain unset`); 301 | } 302 | 303 | // Legacy support for old config properties 304 | if (cfg.entity) newConfigData.entity = cfg.entity; 305 | if (cfg.sensor_entity) newConfigData.sensor_entity = cfg.sensor_entity; 306 | 307 | this._config = newConfigData; 308 | this._configFullyLoaded = true; 309 | 310 | if (JSON.stringify(oldConfig) !== JSON.stringify(this._config)) { 311 | this.dispatchEvent( 312 | new CustomEvent("config-changed", { detail: { config: this._config } }) 313 | ); 314 | } else { 315 | console.log(`TimerCardEditor: Config unchanged, not dispatching event`); 316 | } 317 | 318 | this.requestUpdate(); 319 | } 320 | 321 | connectedCallback() { 322 | super.connectedCallback(); 323 | if (this.hass) { 324 | this._fetchTimerInstances(); 325 | } else { 326 | console.warn("TimerCardEditor: hass not available on connectedCallback. Deferring instance fetch."); 327 | } 328 | } 329 | 330 | updated(changedProperties: Map): void { 331 | super.updated(changedProperties); 332 | if (changedProperties.has("hass") && this.hass) { 333 | if ((changedProperties.get("hass") as any)?.states !== this.hass.states || this._timerInstancesOptions.length === 0) { 334 | this._fetchTimerInstances(); 335 | } 336 | } 337 | } 338 | 339 | async _fetchTimerInstances() { 340 | if (this.hass) { 341 | 342 | this._timerInstancesOptions = await this._getSimpleTimerInstances(); 343 | 344 | if (!this._configFullyLoaded) { 345 | this.requestUpdate(); 346 | return; 347 | } 348 | 349 | // Only validate that existing configured instances still exist 350 | if (this._config?.timer_instance_id && this._timerInstancesOptions.length > 0) { 351 | const currentInstanceExists = this._timerInstancesOptions.some( 352 | instance => instance.value === this._config!.timer_instance_id 353 | ); 354 | 355 | if (!currentInstanceExists) { 356 | console.warn(`TimerCardEditor: Previously configured instance '${this._config.timer_instance_id}' no longer exists. User will need to select a new instance.`); 357 | // Clear the invalid instance ID so user sees "Please select an instance" 358 | const updatedConfig: TimerCardConfig = { 359 | ...this._config, 360 | timer_instance_id: null 361 | }; 362 | 363 | this._config = updatedConfig; 364 | this.dispatchEvent( 365 | new CustomEvent("config-changed", { 366 | detail: { config: this._config }, 367 | bubbles: true, 368 | composed: true, 369 | }), 370 | ); 371 | } 372 | } else { 373 | console.info(`TimerCardEditor: No timer_instance_id configured or no instances available. User must manually select.`); 374 | } 375 | 376 | this.requestUpdate(); 377 | } 378 | } 379 | 380 | _handleNewTimerInput(event: InputEvent): void { 381 | const target = event.target as HTMLInputElement; 382 | this._newTimerButtonValue = target.value; 383 | } 384 | 385 | _addTimerButton(): void { 386 | const val = this._newTimerButtonValue.trim(); 387 | if (!val) return; 388 | 389 | // Validate using the same regex as the card 390 | const match = val.match(/^(\d+(?:\.\d+)?)\s*(s|sec|seconds|m|min|minutes|h|hr|hours|d|day|days)?$/i); 391 | 392 | if (!match) { 393 | alert("Invalid format! Use format like: 30, 30s, 10m, 1.5h, 1d"); 394 | return; 395 | } 396 | 397 | const numVal = parseFloat(match[1]); 398 | const isFloat = match[1].includes('.'); 399 | const unitStr = (match[2] || 'min').toLowerCase(); 400 | const isHours = unitStr.startsWith('h'); 401 | const isDays = unitStr.startsWith('d'); 402 | 403 | // User Restriction: Limit to 9999 for all units 404 | if (numVal > 9999) { 405 | alert("Value cannot exceed 9999"); 406 | return; 407 | } 408 | 409 | // User Restriction: Fractional numbers only allowed for hours and days 410 | if (isFloat && !isHours && !isDays) { 411 | alert("Fractional values are only allowed for Hours (h) and Days (d)"); 412 | return; 413 | } 414 | 415 | // User Restriction: Max 1 digit after decimal for hours and days 416 | if (isFloat && (isHours || isDays)) { 417 | const decimalPart = match[1].split('.')[1]; 418 | if (decimalPart && decimalPart.length > 1) { 419 | alert("Maximum 1 decimal place allowed (e.g. 1.5)"); 420 | return; 421 | } 422 | } 423 | 424 | // NEW CHECK: Must be greater than 0 425 | // Internal calculation used by card to ignore zero values 426 | let minutesCheck = numVal; 427 | if (unitStr.startsWith('s')) minutesCheck = numVal / 60; 428 | else if (unitStr.startsWith('h')) minutesCheck = numVal * 60; 429 | else if (unitStr.startsWith('d')) minutesCheck = numVal * 1440; 430 | 431 | if (minutesCheck <= 0) { 432 | alert("Timer duration must be greater than 0"); 433 | return; 434 | } 435 | 436 | let currentButtons = Array.isArray(this._config?.timer_buttons) ? [...this._config!.timer_buttons] : []; 437 | 438 | // Normalize logic: Store numbers as numbers (minutes), strings as strings (with units) 439 | // If user enters "30", treat as 30 min (number) 440 | // If user enters "30m", treat as "30m" (string)? OR normalize "30m" -> 30? 441 | // Current backend/frontend supports mixed. Let's keep it simple: if valid, add as string unless it's pure number 442 | 443 | let valueToAdd: string | number = val; 444 | // Optional: normalize pure numbers to number type for consistency with legacy, 445 | // but the regex allows units. 446 | // If no unit provided, match[2] is undefined. 447 | if (!match[2]) { 448 | valueToAdd = numVal; 449 | } 450 | 451 | // Check for duplicates 452 | if (currentButtons.includes(valueToAdd)) { 453 | this._newTimerButtonValue = ""; // Clear input anyway 454 | this.requestUpdate(); 455 | return; 456 | } 457 | 458 | currentButtons.push(valueToAdd); 459 | 460 | // Sort logic 461 | const numbers = currentButtons.filter(b => typeof b === 'number') as number[]; 462 | const strings = currentButtons.filter(b => typeof b === 'string') as string[]; 463 | numbers.sort((a, b) => a - b); 464 | strings.sort((a, b) => { 465 | // Try to sort strings naturally? simplified sort for now 466 | return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); 467 | }); 468 | 469 | currentButtons = [...numbers, ...strings]; 470 | 471 | this._updateConfig({ timer_buttons: currentButtons }); 472 | this._newTimerButtonValue = ""; 473 | this.requestUpdate(); 474 | } 475 | 476 | _removeTimerButton(valueToRemove: string | number): void { 477 | let currentButtons = Array.isArray(this._config?.timer_buttons) ? [...this._config!.timer_buttons] : []; 478 | currentButtons = currentButtons.filter(b => b !== valueToRemove); 479 | this._updateConfig({ timer_buttons: currentButtons }); 480 | } 481 | 482 | _updateConfig(updates: Partial) { 483 | const updatedConfig = { ...this._config, ...updates }; 484 | this._config = updatedConfig; 485 | this.dispatchEvent( 486 | new CustomEvent("config-changed", { 487 | detail: { config: this._config }, 488 | bubbles: true, 489 | composed: true, 490 | }), 491 | ); 492 | this.requestUpdate(); 493 | } 494 | 495 | render() { 496 | if (!this.hass) return html``; 497 | 498 | const timerInstances = this._timerInstancesOptions || []; 499 | const instanceOptions = [{ value: "", label: "None" }]; 500 | const v = this._tempSliderMaxValue ?? String(this._config.slider_max ?? 120); 501 | 502 | if (timerInstances.length > 0) { 503 | instanceOptions.push(...timerInstances); 504 | } else { 505 | instanceOptions.push({ value: "none_found", label: "No Simple Timer Instances Found" }); 506 | } 507 | 508 | // Get actual theme colors for defaults 509 | const defaultSliderThumbColor = "#2ab69c"; 510 | const defaultSliderBackgroundColor = this._getThemeColorHex('--secondary-background-color', '#424242'); 511 | const defaultTimerButtonFontColor = this._getThemeColorHex('--primary-text-color', '#ffffff'); 512 | const defaultTimerButtonBackgroundColor = this._getThemeColorHex('--secondary-background-color', '#424242'); 513 | const defaultPowerButtonBackgroundColor = this._getThemeColorHex('--secondary-background-color', '#424242'); 514 | const defaultPowerButtonIconColor = this._getThemeColorHex('--primary-color', '#03a9f4'); 515 | 516 | return html` 517 |
518 |
519 | 526 |
527 | 528 |
529 | ev.stopPropagation()} 535 | fixedMenuPosition 536 | naturalMenuWidth 537 | required 538 | > 539 | ${instanceOptions.map(option => html` 540 | 541 | ${option.label} 542 | 543 | `)} 544 | 545 |
546 | 547 |
548 | 556 | ${this._config?.power_button_icon ? html` 557 | 558 | ` : ''} 559 | 560 |
561 | 562 |
563 |
564 | 565 |
566 | { 570 | const target = ev.target as HTMLInputElement; 571 | this._valueChanged({ 572 | target: { 573 | configValue: "slider_thumb_color", 574 | value: target.value 575 | }, 576 | stopPropagation: () => { } 577 | } as any); 578 | }} 579 | style="width: 40px; height: 40px; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;" 580 | /> 581 | 590 |
591 | 592 | 593 |
594 | { 598 | const target = ev.target as HTMLInputElement; 599 | this._valueChanged({ 600 | target: { 601 | configValue: "slider_background_color", 602 | value: target.value 603 | }, 604 | stopPropagation: () => { } 605 | } as any); 606 | }} 607 | style="width: 40px; height: 40px; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;" 608 | /> 609 | 618 |
619 |
620 |
621 | 622 |
623 |
624 | 625 |
626 | { 630 | const target = ev.target as HTMLInputElement; 631 | this._valueChanged({ 632 | target: { 633 | configValue: "timer_button_font_color", 634 | value: target.value 635 | }, 636 | stopPropagation: () => { } 637 | } as any); 638 | }} 639 | style="width: 40px; height: 40px; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;" 640 | /> 641 | 650 |
651 | 652 | 653 |
654 | { 658 | const target = ev.target as HTMLInputElement; 659 | this._valueChanged({ 660 | target: { 661 | configValue: "timer_button_background_color", 662 | value: target.value 663 | }, 664 | stopPropagation: () => { } 665 | } as any); 666 | }} 667 | style="width: 40px; height: 40px; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;" 668 | /> 669 | 678 |
679 |
680 |
681 | 682 |
683 |
684 | 685 |
686 | { 690 | const target = ev.target as HTMLInputElement; 691 | this._valueChanged({ 692 | target: { 693 | configValue: "power_button_background_color", 694 | value: target.value 695 | }, 696 | stopPropagation: () => { } 697 | } as any); 698 | }} 699 | style="width: 40px; height: 40px; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;" 700 | /> 701 | 710 |
711 | 712 | 713 |
714 | { 718 | const target = ev.target as HTMLInputElement; 719 | this._valueChanged({ 720 | target: { 721 | configValue: "power_button_icon_color", 722 | value: target.value 723 | }, 724 | stopPropagation: () => { } 725 | } as any); 726 | }} 727 | style="width: 40px; height: 40px; border: none; border-radius: 4px; cursor: pointer; flex-shrink: 0;" 728 | /> 729 | 738 |
739 |
740 |
741 | 742 |
743 |
744 | { if (e.key === 'Enter') this._handleSliderMaxBlur(e as any); }} 758 | > 759 | 760 | ev.stopPropagation()} 766 | fixedMenuPosition 767 | naturalMenuWidth 768 | > 769 | Seconds (s) 770 | Minutes (m) 771 | Hours (h) 772 | Days (d) 773 | 774 |
775 |
776 | 777 |
778 | 779 | 784 | 785 |
786 | 787 |
788 | 789 | 794 | 795 |
796 | 797 |
798 | 799 | 804 | 805 |
806 | 807 |
808 | 809 |
810 |
811 | 812 |
813 | ${(this._config?.timer_buttons || DEFAULT_TIMER_BUTTONS).map(btn => html` 814 |
815 | ${typeof btn === 'number' ? btn + 'm' : btn} 816 | this._removeTimerButton(btn)}>✕ 817 |
818 | `)} 819 |
820 |
821 | 822 |
823 | { if (e.key === 'Enter') this._addTimerButton(); }} 828 | style="flex: 1;" 829 | > 830 |
ADD
831 |
832 |
833 | Supports seconds (s), minutes (m), hours (h), days (d). Example: 30s, 10, 1.5h, 1d 834 |
835 |
836 | ${(!this._config?.timer_buttons?.length && this._config?.hide_slider) ? html` 837 |

ℹ️ No timer presets logic and the Slider is also hidden. The card will not be able to set a duration.

838 | ` : ''} 839 | 840 | 841 | `; 842 | } 843 | 844 | private _onSliderMaxInput(ev: Event) { 845 | const target = ev.currentTarget as HTMLInputElement; 846 | this._tempSliderMaxValue = target.value; // do NOT clamp here 847 | this.requestUpdate(); // makes ?invalid update live 848 | } 849 | 850 | private _isSliderMaxInvalid(): boolean { 851 | const raw = this._tempSliderMaxValue ?? String(this._config.slider_max ?? ""); 852 | if (raw === "") return true; // empty = invalid while editing 853 | const n = Number(raw); 854 | if (!Number.isFinite(n)) return true; 855 | return !(n >= 1 && n <= 9999); // enforce 1–9999 (no negatives) 856 | } 857 | 858 | _valueChanged(ev: Event): void { 859 | ev.stopPropagation(); 860 | const target = ev.target as any; 861 | 862 | if (!this._config || !target.configValue) { 863 | return; 864 | } 865 | 866 | const configValue = target.configValue; 867 | let value; 868 | 869 | if (target.checked !== undefined) { 870 | value = target.checked; 871 | } else if (target.selected !== undefined) { 872 | value = target.value; 873 | } else if (target.value !== undefined) { 874 | value = target.value; 875 | } else { 876 | return; 877 | } 878 | 879 | const updatedConfig: TimerCardConfig = { 880 | type: this._config.type || "custom:timer-card", 881 | timer_buttons: this._config.timer_buttons 882 | }; 883 | 884 | // Handle specific field updates 885 | if (configValue === "card_title") { 886 | if (value && value !== '') { 887 | updatedConfig.card_title = value; 888 | } else { 889 | delete updatedConfig.card_title; 890 | } 891 | } else if (configValue === "timer_instance_id") { 892 | if (value && value !== "none_found" && value !== "") { 893 | updatedConfig.timer_instance_id = value; 894 | } else { 895 | updatedConfig.timer_instance_id = null; 896 | } 897 | } else if (configValue === "power_button_icon") { 898 | updatedConfig.power_button_icon = value || null; 899 | } else if (configValue === "slider_thumb_color") { 900 | updatedConfig.slider_thumb_color = value || null; 901 | } else if (configValue === "slider_background_color") { 902 | updatedConfig.slider_background_color = value || null; 903 | } else if (configValue === "timer_button_font_color") { 904 | updatedConfig.timer_button_font_color = value || null; 905 | } else if (configValue === "timer_button_background_color") { 906 | updatedConfig.timer_button_background_color = value || null; 907 | } else if (configValue === "power_button_background_color") { 908 | updatedConfig.power_button_background_color = value || null; 909 | } else if (configValue === "power_button_icon_color") { 910 | updatedConfig.power_button_icon_color = value || null; 911 | } else if (configValue === "reverse_mode") { 912 | updatedConfig.reverse_mode = value; 913 | } else if (configValue === "hide_slider") { 914 | updatedConfig.hide_slider = value; 915 | } else if (configValue === "show_daily_usage") { 916 | updatedConfig.show_daily_usage = value; 917 | } else if (configValue === "slider_unit") { 918 | updatedConfig.slider_unit = value; 919 | } 920 | 921 | // Preserve existing values 922 | if (this._config.entity) updatedConfig.entity = this._config.entity; 923 | if (this._config.sensor_entity) updatedConfig.sensor_entity = this._config.sensor_entity; 924 | if (this._config.timer_instance_id && configValue !== "timer_instance_id") { 925 | updatedConfig.timer_instance_id = this._config.timer_instance_id; 926 | } 927 | if (this._config.card_title && configValue !== "card_title") { 928 | updatedConfig.card_title = this._config.card_title; 929 | } 930 | if (this._config.power_button_icon !== undefined && configValue !== "power_button_icon") { 931 | updatedConfig.power_button_icon = this._config.power_button_icon; 932 | } 933 | if (this._config.slider_max !== undefined && configValue !== "slider_max") { 934 | updatedConfig.slider_max = this._config.slider_max; 935 | } 936 | if (this._config.reverse_mode !== undefined && configValue !== "reverse_mode") { 937 | updatedConfig.reverse_mode = this._config.reverse_mode; 938 | } 939 | if (this._config.hide_slider !== undefined && configValue !== "hide_slider") { 940 | updatedConfig.hide_slider = this._config.hide_slider; 941 | } 942 | if (this._config.slider_unit !== undefined && configValue !== "slider_unit") { 943 | updatedConfig.slider_unit = this._config.slider_unit; 944 | } 945 | if (this._config.show_daily_usage !== undefined && configValue !== "show_daily_usage") { 946 | updatedConfig.show_daily_usage = this._config.show_daily_usage; 947 | } 948 | if (this._config.slider_thumb_color !== undefined && configValue !== "slider_thumb_color") { 949 | updatedConfig.slider_thumb_color = this._config.slider_thumb_color; 950 | } 951 | if (this._config.slider_background_color !== undefined && configValue !== "slider_background_color") { 952 | updatedConfig.slider_background_color = this._config.slider_background_color; 953 | } 954 | if (this._config.timer_button_font_color !== undefined && configValue !== "timer_button_font_color") { 955 | updatedConfig.timer_button_font_color = this._config.timer_button_font_color; 956 | } 957 | if (this._config.timer_button_background_color !== undefined && configValue !== "timer_button_background_color") { 958 | updatedConfig.timer_button_background_color = this._config.timer_button_background_color; 959 | } 960 | if (this._config.power_button_background_color !== undefined && configValue !== "power_button_background_color") { 961 | updatedConfig.power_button_background_color = this._config.power_button_background_color; 962 | } 963 | if (this._config.power_button_icon_color !== undefined && configValue !== "power_button_icon_color") { 964 | updatedConfig.power_button_icon_color = this._config.power_button_icon_color; 965 | } 966 | 967 | if (JSON.stringify(this._config) !== JSON.stringify(updatedConfig)) { 968 | this._config = updatedConfig; 969 | 970 | // Clean up any old notification/show_seconds properties when saving 971 | const cleanConfig: any = { ...updatedConfig }; 972 | delete cleanConfig.notification_entity; 973 | delete cleanConfig.show_seconds; 974 | 975 | this.dispatchEvent( 976 | new CustomEvent("config-changed", { 977 | detail: { config: cleanConfig }, 978 | bubbles: true, 979 | composed: true, 980 | }), 981 | ); 982 | this.requestUpdate(); 983 | } 984 | } 985 | 986 | private _handleSliderMaxBlur(ev: Event) { 987 | const target = ev.currentTarget as HTMLInputElement; 988 | const raw = (target.value ?? "").trim(); 989 | const n = Number(raw); 990 | const isInvalid = !raw || !Number.isFinite(n) || n < 1 || n > 9999; 991 | 992 | const newMax = isInvalid ? 120 : Math.trunc(n); 993 | target.value = String(newMax); 994 | this._tempSliderMaxValue = null; 995 | 996 | // Clamp existing timer buttons to newMax 997 | let newButtons = [...(this._config.timer_buttons || [])]; 998 | newButtons = newButtons.filter(val => { 999 | if (typeof val === 'number') { 1000 | return val <= newMax; 1001 | } 1002 | return true; // Keep custom string buttons 1003 | }); 1004 | 1005 | const updated: TimerCardConfig = { 1006 | ...this._config, 1007 | slider_max: newMax, 1008 | timer_buttons: newButtons 1009 | }; 1010 | 1011 | this._config = updated; 1012 | 1013 | this.dispatchEvent(new CustomEvent("config-changed", { 1014 | detail: { config: updated }, bubbles: true, composed: true 1015 | })); 1016 | this.requestUpdate(); 1017 | } 1018 | 1019 | static get styles() { 1020 | return editorCardStyles; 1021 | } 1022 | } 1023 | 1024 | customElements.define("timer-card-editor", TimerCardEditor); -------------------------------------------------------------------------------- /src/timer-card.ts: -------------------------------------------------------------------------------- 1 | // timer-card.ts 2 | 3 | import { LitElement, html } from 'lit'; 4 | import { cardStyles } from './timer-card.styles'; 5 | 6 | 7 | interface HAState { 8 | entity_id: string; 9 | state: string; 10 | attributes: { 11 | friendly_name?: string; 12 | entry_id?: string; 13 | switch_entity_id?: string; 14 | timer_state?: 'active' | 'idle'; 15 | timer_finishes_at?: string; 16 | timer_duration?: number; 17 | watchdog_message?: string; 18 | show_seconds?: boolean; // This comes from backend now 19 | reset_time?: string; // NEW: Reset time from backend 20 | [key: string]: any; 21 | }; 22 | last_changed: string; 23 | last_updated: string; 24 | context: { 25 | id: string; 26 | parent_id: string | null; 27 | user_id: string | null; 28 | }; 29 | } 30 | 31 | interface HomeAssistant { 32 | states: { 33 | [entityId: string]: HAState; 34 | }; 35 | services: { 36 | [domain: string]: { [service: string]: any } | undefined; 37 | }; 38 | callService(domain: string, service: string, data?: Record): Promise; 39 | callApi(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, parameters?: Record, headers?: Record): Promise; 40 | config: { 41 | components: { 42 | [domain: string]: { 43 | config_entries: { [entry_id: string]: unknown }; 44 | }; 45 | }; 46 | [key: string]: any; 47 | }; 48 | } 49 | 50 | const DOMAIN = "simple_timer"; 51 | const CARD_VERSION = "1.3.62"; 52 | const DEFAULT_TIMER_BUTTONS = [15, 30, 60, 90, 120, 150]; // Default for new cards only 53 | 54 | console.info( 55 | `%c SIMPLE-TIMER-CARD %c v${CARD_VERSION} `, 56 | 'color: orange; font-weight: bold; background: black', 57 | 'color: white; font-weight: bold; background: dimgray', 58 | ); 59 | 60 | interface TimerButton { 61 | displayValue: number; 62 | unit: string; // 'min', 's', 'h' 63 | labelUnit: string; // 'Min', 'Sec', 'Hr' 64 | minutesEquivalent: number; 65 | } 66 | 67 | class TimerCard extends LitElement { 68 | static get properties() { 69 | return { 70 | hass: { type: Object }, 71 | _config: { type: Object }, 72 | _timeRemaining: { state: true }, 73 | _sliderValue: { state: true }, 74 | _entitiesLoaded: { state: true }, 75 | _effectiveSwitchEntity: { state: true }, 76 | _effectiveSensorEntity: { state: true }, 77 | _validationMessages: { state: true }, 78 | }; 79 | } 80 | 81 | hass?: HomeAssistant; 82 | _config?: TimerCardConfig; 83 | 84 | _countdownInterval: number | null = null; 85 | _liveRuntimeSeconds: number = 0; 86 | 87 | _timeRemaining: string | null = null; 88 | _sliderValue: number = 0; 89 | 90 | buttons: TimerButton[] = []; 91 | _validationMessages: string[] = []; 92 | _notificationSentForCurrentCycle: boolean = false; 93 | _entitiesLoaded: boolean = false; 94 | 95 | _effectiveSwitchEntity: string | null = null; 96 | _effectiveSensorEntity: string | null = null; 97 | 98 | _longPressTimer: number | null = null; 99 | _isLongPress: boolean = false; 100 | _touchStartPosition: { x: number; y: number } | null = null; 101 | _isCancelling: boolean = false; 102 | 103 | static async getConfigElement(): Promise { 104 | await import("./timer-card-editor.js"); 105 | return document.createElement("timer-card-editor"); 106 | } 107 | 108 | static getStubConfig(_hass: HomeAssistant): TimerCardConfig { 109 | console.log("TimerCard: Generating stub config - NO auto-selection will be performed"); 110 | 111 | return { 112 | type: "custom:timer-card", 113 | timer_instance_id: null, // Changed from auto-selected instance to null 114 | timer_buttons: [...DEFAULT_TIMER_BUTTONS], // Use default buttons 115 | card_title: "Simple Timer", 116 | power_button_icon: "mdi:power", 117 | hide_slider: false, 118 | slider_thumb_color: null, 119 | slider_background_color: null, 120 | power_button_background_color: null, 121 | power_button_icon_color: null 122 | }; 123 | } 124 | 125 | setConfig(cfg: TimerCardConfig): void { 126 | const newSliderMax = cfg.slider_max && cfg.slider_max > 0 && cfg.slider_max <= 9999 ? cfg.slider_max : 120; 127 | const instanceId = cfg.timer_instance_id || 'default'; 128 | 129 | this.buttons = this._getValidatedTimerButtons(cfg.timer_buttons); 130 | 131 | this._config = { 132 | type: cfg.type || "custom:timer-card", 133 | timer_buttons: cfg.timer_buttons || [...DEFAULT_TIMER_BUTTONS], 134 | card_title: cfg.card_title || null, 135 | power_button_icon: cfg.power_button_icon || null, 136 | slider_max: newSliderMax, 137 | slider_unit: cfg.slider_unit || 'min', 138 | reverse_mode: cfg.reverse_mode || false, 139 | hide_slider: cfg.hide_slider || false, 140 | show_daily_usage: cfg.show_daily_usage !== false, 141 | timer_instance_id: instanceId, 142 | entity: cfg.entity, 143 | sensor_entity: cfg.sensor_entity, 144 | slider_thumb_color: cfg.slider_thumb_color || null, 145 | slider_background_color: cfg.slider_background_color || null, 146 | timer_button_font_color: cfg.timer_button_font_color || null, 147 | timer_button_background_color: cfg.timer_button_background_color || null, 148 | power_button_background_color: cfg.power_button_background_color || null, 149 | power_button_icon_color: cfg.power_button_icon_color || null 150 | }; 151 | 152 | if (cfg.timer_instance_id) { 153 | this._config.timer_instance_id = cfg.timer_instance_id; 154 | } 155 | if (cfg.entity) { 156 | this._config.entity = cfg.entity; 157 | } 158 | if (cfg.sensor_entity) { 159 | this._config.sensor_entity = cfg.sensor_entity; 160 | } 161 | 162 | // Always initialize from localStorage 163 | const saved = localStorage.getItem(`simple-timer-slider-${instanceId}`); 164 | let parsed = saved ? parseInt(saved) : NaN; 165 | if (isNaN(parsed) || parsed <= 0) { 166 | parsed = newSliderMax; 167 | } 168 | 169 | // Clamp if needed 170 | if (parsed > newSliderMax) { 171 | parsed = newSliderMax; 172 | } 173 | 174 | this._sliderValue = parsed; 175 | localStorage.setItem(`simple-timer-slider-${instanceId}`, this._sliderValue.toString()); 176 | 177 | this.requestUpdate(); 178 | 179 | 180 | this._liveRuntimeSeconds = 0; 181 | this._notificationSentForCurrentCycle = false; 182 | this._effectiveSwitchEntity = null; 183 | this._effectiveSensorEntity = null; 184 | this._entitiesLoaded = false; 185 | } 186 | 187 | _getValidatedTimerButtons(configButtons: any): TimerButton[] { 188 | let validatedTimerButtons: TimerButton[] = []; 189 | this._validationMessages = []; 190 | 191 | if (Array.isArray(configButtons)) { 192 | const invalidValues: any[] = []; 193 | const uniqueValues = new Set(); // Use string representation for uniqueness 194 | const duplicateValues: any[] = []; 195 | 196 | configButtons.forEach(val => { 197 | let displayValue: number; 198 | let unit = 'min'; 199 | let labelUnit = 'Min'; 200 | let minutesEquivalent: number; 201 | 202 | const strVal = String(val).trim().toLowerCase(); 203 | 204 | // Match numbers (including decimals) optionally followed by unit 205 | const match = strVal.match(/^(\d+(?:\.\d+)?)\s*(s|sec|seconds|m|min|minutes|h|hr|hours|d|day|days)?$/); 206 | 207 | if (match) { 208 | const numVal = parseFloat(match[1]); 209 | const isFloat = match[1].includes('.'); 210 | const unitStr = match[2] || 'min'; 211 | const isHours = unitStr.startsWith('h'); 212 | const isDays = unitStr.startsWith('d'); 213 | 214 | // User Restriction: Limit to 9999 for all units 215 | if (numVal > 9999) { 216 | invalidValues.push(val); 217 | return; 218 | } 219 | 220 | // User Restriction: Fractional numbers only allowed for hours and days 221 | if (isFloat && !isHours && !isDays) { 222 | invalidValues.push(val); 223 | return; 224 | } 225 | 226 | // User Restriction: Max 1 digit after decimal for hours and days 227 | if (isFloat && (isHours || isDays)) { 228 | const decimalPart = match[1].split('.')[1]; 229 | if (decimalPart && decimalPart.length > 1) { 230 | invalidValues.push(val); 231 | return; 232 | } 233 | } 234 | 235 | displayValue = numVal; 236 | 237 | if (unitStr.startsWith('s')) { 238 | unit = 's'; 239 | labelUnit = 'sec'; 240 | minutesEquivalent = displayValue / 60; 241 | } else if (unitStr.startsWith('h')) { 242 | unit = 'h'; 243 | labelUnit = 'hr'; 244 | minutesEquivalent = displayValue * 60; 245 | } else if (unitStr.startsWith('d')) { 246 | unit = 'd'; 247 | labelUnit = 'day'; 248 | minutesEquivalent = displayValue * 1440; 249 | } else { 250 | unit = 'min'; 251 | labelUnit = 'min'; 252 | minutesEquivalent = displayValue; 253 | } 254 | 255 | if (displayValue > 0) { 256 | const uniqueKey = `${minutesEquivalent}`; 257 | if (uniqueValues.has(uniqueKey)) { 258 | duplicateValues.push(val); 259 | } else { 260 | uniqueValues.add(uniqueKey); 261 | validatedTimerButtons.push({ displayValue, unit, labelUnit, minutesEquivalent }); 262 | } 263 | } else { 264 | invalidValues.push(val); 265 | } 266 | } else { 267 | invalidValues.push(val); 268 | } 269 | }); 270 | 271 | const messages: string[] = []; 272 | if (invalidValues.length > 0) { 273 | messages.push(`Invalid timer values ignored: ${invalidValues.join(', ')}. Format example: 30, "30s", "1h", "2d". Limit 9999.`); 274 | } 275 | if (duplicateValues.length > 0) { 276 | messages.push(`Duplicate timer values were removed.`); 277 | } 278 | this._validationMessages = messages; 279 | 280 | validatedTimerButtons.sort((a, b) => a.minutesEquivalent - b.minutesEquivalent); 281 | return validatedTimerButtons; 282 | } 283 | 284 | if (configButtons === undefined || configButtons === null) { 285 | return []; 286 | } 287 | 288 | console.warn(`TimerCard: Invalid timer_buttons type (${typeof configButtons}):`, configButtons, `- using empty array`); 289 | this._validationMessages = [`Invalid timer_buttons configuration. Expected array, got ${typeof configButtons}.`]; 290 | return []; 291 | } 292 | 293 | _determineEffectiveEntities(): void { 294 | let currentSwitch: string | null = null; 295 | let currentSensor: string | null = null; 296 | let entitiesAreValid = false; 297 | 298 | if (!this.hass || !this.hass.states) { 299 | this._entitiesLoaded = false; 300 | return; 301 | } 302 | 303 | if (this._config?.timer_instance_id) { 304 | const targetEntryId = this._config.timer_instance_id; 305 | const allSensors = Object.keys(this.hass.states).filter(entityId => entityId.startsWith('sensor.')); 306 | const instanceSensor = allSensors.find(entityId => { 307 | const state = this.hass!.states[entityId]; 308 | return state.attributes.entry_id === targetEntryId && 309 | typeof state.attributes.switch_entity_id === 'string'; 310 | }); 311 | 312 | if (instanceSensor) { 313 | const sensorState = this.hass.states[instanceSensor]; 314 | currentSensor = instanceSensor; 315 | currentSwitch = sensorState.attributes.switch_entity_id as string | null; 316 | 317 | if (currentSwitch && this.hass.states[currentSwitch]) { 318 | entitiesAreValid = true; 319 | } else { 320 | console.warn(`TimerCard: Configured instance '${targetEntryId}' sensor '${currentSensor}' links to missing or invalid switch '${currentSwitch}'.`); 321 | } 322 | } else { 323 | console.warn(`TimerCard: Configured timer_instance_id '${targetEntryId}' does not have a corresponding simple_timer sensor found.`); 324 | } 325 | } 326 | 327 | if (!entitiesAreValid && this._config?.sensor_entity) { 328 | const sensorState = this.hass.states[this._config.sensor_entity]; 329 | if (sensorState && typeof sensorState.attributes.entry_id === 'string' && typeof sensorState.attributes.switch_entity_id === 'string') { 330 | currentSensor = this._config.sensor_entity; 331 | currentSwitch = sensorState.attributes.switch_entity_id as string | null; 332 | if (currentSwitch && this.hass.states[currentSwitch]) { 333 | entitiesAreValid = true; 334 | console.info(`TimerCard: Using manually configured sensor_entity: Sensor '${currentSensor}', Switch '${currentSwitch}'.`); 335 | } else { 336 | console.warn(`TimerCard: Manually configured sensor '${currentSensor}' links to missing or invalid switch '${currentSwitch}'.`); 337 | } 338 | } else { 339 | console.warn(`TimerCard: Manually configured sensor_entity '${this._config.sensor_entity}' not found or missing required attributes.`); 340 | } 341 | } 342 | 343 | if (this._effectiveSwitchEntity !== currentSwitch || this._effectiveSensorEntity !== currentSensor) { 344 | this._effectiveSwitchEntity = currentSwitch; 345 | this._effectiveSensorEntity = currentSensor; 346 | this.requestUpdate(); 347 | } 348 | 349 | this._entitiesLoaded = entitiesAreValid; 350 | } 351 | 352 | _getEntryId(): string | null { 353 | if (!this._effectiveSensorEntity || !this.hass || !this.hass.states) { 354 | console.error("Timer-card: _getEntryId called without a valid effective sensor entity."); 355 | return null; 356 | } 357 | const sensor = this.hass.states[this._effectiveSensorEntity]; 358 | if (sensor && sensor.attributes.entry_id) { 359 | return sensor.attributes.entry_id; 360 | } 361 | console.error("Could not determine entry_id from effective sensor_entity attributes:", this._effectiveSensorEntity); 362 | return null; 363 | } 364 | 365 | _startTimer(minutes: number, unit: string = 'min', startMethod: 'button' | 'slider' = 'button'): void { 366 | this._validationMessages = []; 367 | if (!this._entitiesLoaded || !this.hass || !this.hass.callService) { 368 | console.error("Timer-card: Cannot start timer. Entities not loaded or callService unavailable."); 369 | return; 370 | } 371 | 372 | const entryId = this._getEntryId(); 373 | if (!entryId) { console.error("Timer-card: Entry ID not found for starting timer."); return; } 374 | 375 | const switchId = this._effectiveSwitchEntity!; 376 | const reverseMode = this._config?.reverse_mode || false; 377 | 378 | if (reverseMode) { 379 | // REVERSE MODE: Ensure switch is OFF, then start timer 380 | this.hass.callService("homeassistant", "turn_off", { entity_id: switchId }) 381 | .then(() => { 382 | // Pass reverse mode info to backend via service call data 383 | this.hass!.callService(DOMAIN, "start_timer", { 384 | entry_id: entryId, 385 | duration: minutes, 386 | unit: unit, 387 | reverse_mode: true, 388 | start_method: startMethod 389 | }); 390 | }) 391 | .catch(error => { 392 | console.error("Timer-card: Error turning off switch for reverse timer:", error); 393 | }); 394 | } else { 395 | // NORMAL MODE: Turn ON switch, then start timer 396 | this.hass.callService("homeassistant", "turn_on", { entity_id: switchId }) 397 | .then(() => { 398 | this.hass!.callService(DOMAIN, "start_timer", { entry_id: entryId, duration: minutes, unit: unit, start_method: startMethod }); 399 | }) 400 | .catch(error => { 401 | console.error("Timer-card: Error turning on switch or starting timer:", error); 402 | }); 403 | } 404 | 405 | this._notificationSentForCurrentCycle = false; 406 | } 407 | 408 | _cancelTimer(): void { 409 | this._validationMessages = []; 410 | if (!this._entitiesLoaded || !this.hass || !this.hass.callService) { 411 | console.error("Timer-card: Cannot cancel timer. Entities not loaded or callService unavailable."); 412 | return; 413 | } 414 | 415 | // Set flag to prevent immediate restart 416 | this._isCancelling = true; 417 | 418 | const entryId = this._getEntryId(); 419 | if (!entryId) { 420 | console.error("Timer-card: Entry ID not found for cancelling timer."); 421 | this._isCancelling = false; 422 | return; 423 | } 424 | 425 | this.hass.callService(DOMAIN, "cancel_timer", { entry_id: entryId }) 426 | .then(() => { 427 | // Reset flag after a short delay to ensure state has settled 428 | setTimeout(() => { 429 | this._isCancelling = false; 430 | }, 1000); 431 | }) 432 | .catch(error => { 433 | console.error("Timer-card: Error cancelling timer:", error); 434 | this._isCancelling = false; 435 | }); 436 | 437 | this._notificationSentForCurrentCycle = false; 438 | } 439 | 440 | _togglePower(): void { 441 | this._validationMessages = []; 442 | if (!this._entitiesLoaded || !this.hass || !this.hass.states || !this.hass.callService) { 443 | console.error("Timer-card: Cannot toggle power. Entities not loaded or services unavailable."); 444 | return; 445 | } 446 | 447 | // Don't do anything if we're in the middle of cancelling 448 | if (this._isCancelling) { 449 | return; 450 | } 451 | 452 | const switchId = this._effectiveSwitchEntity!; 453 | const sensorId = this._effectiveSensorEntity!; 454 | 455 | const timerSwitch = this.hass.states[switchId]; 456 | if (!timerSwitch) { 457 | console.warn(`Timer-card: Switch entity '${switchId}' not found during toggle.`); 458 | return; 459 | } 460 | 461 | const sensor = this.hass.states[sensorId]; 462 | const isTimerActive = sensor && sensor.attributes.timer_state === 'active'; 463 | 464 | // PRIORITY 1: Check for active timer first (regardless of switch state) 465 | if (isTimerActive) { 466 | // Check if this is a reverse mode timer 467 | const isReverseMode = sensor.attributes.reverse_mode; 468 | 469 | if (isReverseMode) { 470 | // For reverse timers: cancel means "start now instead of waiting" 471 | this._cancelTimer(); 472 | console.log(`Timer-card: Cancelling reverse timer and turning on switch: ${switchId}`); 473 | } else { 474 | // For normal timers: cancel means "stop and turn off" 475 | this._cancelTimer(); 476 | console.log(`Timer-card: Cancelling normal timer for switch: ${switchId}`); 477 | } 478 | return; 479 | } 480 | 481 | // PRIORITY 2: Handle switch state for non-timer operations 482 | if (timerSwitch.state === 'on') { 483 | // Switch is on but no timer active - manual turn off 484 | this.hass.callService(DOMAIN, "manual_power_toggle", { 485 | entry_id: this._getEntryId(), 486 | action: "turn_off" 487 | }); 488 | console.log(`Timer-card: Manually turning off switch: ${switchId}`); 489 | } else { 490 | // Switch is currently OFF and no timer active 491 | if (this._config?.hide_slider) { 492 | // If slider is hidden, just turn on manually (infinite) 493 | this.hass.callService(DOMAIN, "manual_power_toggle", { 494 | entry_id: this._getEntryId(), 495 | action: "turn_on" 496 | }) 497 | .then(() => { 498 | console.log(`Timer-card: Manually turning on switch (infinite, hidden slider): ${switchId}`); 499 | }) 500 | .catch(error => { 501 | console.error("Timer-card: Error manually turning on switch:", error); 502 | }); 503 | } else if (this._sliderValue > 0) { 504 | const unit = this._config?.slider_unit || 'min'; 505 | this._startTimer(this._sliderValue, unit, 'slider'); 506 | console.log(`Timer-card: Starting timer for ${this._sliderValue} ${unit}`); 507 | } else { 508 | // Manual turn on (infinite until manually turned off) 509 | this.hass.callService(DOMAIN, "manual_power_toggle", { 510 | entry_id: this._getEntryId(), 511 | action: "turn_on" 512 | }) 513 | .then(() => { 514 | console.log(`Timer-card: Manually turning on switch (infinite): ${switchId}`); 515 | }) 516 | .catch(error => { 517 | console.error("Timer-card: Error manually turning on switch:", error); 518 | }); 519 | } 520 | this._notificationSentForCurrentCycle = false; 521 | } 522 | } 523 | 524 | _showMoreInfo(): void { 525 | if (!this._entitiesLoaded || !this.hass) { 526 | console.error("Timer-card: Cannot show more info. Entities not loaded."); 527 | return; 528 | } 529 | const sensorId = this._effectiveSensorEntity!; 530 | 531 | const event = new CustomEvent("hass-more-info", { 532 | bubbles: true, 533 | composed: true, 534 | detail: { entityId: sensorId } 535 | }); 536 | this.dispatchEvent(event); 537 | } 538 | 539 | connectedCallback(): void { 540 | super.connectedCallback(); 541 | 542 | // Restore slider value per instance 543 | const instanceId = this._config?.timer_instance_id || 'default'; 544 | const savedValue = localStorage.getItem(`simple-timer-slider-${instanceId}`); 545 | 546 | if (savedValue) { 547 | //this._sliderValue = parseInt(savedValue); 548 | } else { 549 | // Fall back to last timer duration for this instance 550 | this._determineEffectiveEntities(); 551 | if (this._entitiesLoaded && this.hass && this._effectiveSensorEntity) { 552 | const sensor = this.hass.states[this._effectiveSensorEntity]; 553 | const lastDuration = sensor?.attributes?.timer_duration || 0; 554 | if (lastDuration > 0 && lastDuration <= 120) { 555 | this._sliderValue = lastDuration; 556 | } 557 | } 558 | } 559 | 560 | this._determineEffectiveEntities(); 561 | this._updateLiveRuntime(); 562 | this._updateCountdown(); 563 | } 564 | 565 | disconnectedCallback(): void { 566 | super.disconnectedCallback(); 567 | this._stopCountdown(); 568 | this._stopLiveRuntime(); 569 | if (this._longPressTimer) { 570 | window.clearTimeout(this._longPressTimer); 571 | } 572 | } 573 | 574 | updated(changedProperties: Map): void { 575 | if (changedProperties.has("hass") || changedProperties.has("_config")) { 576 | this._determineEffectiveEntities(); 577 | this._updateLiveRuntime(); 578 | this._updateCountdown(); 579 | } 580 | } 581 | 582 | _updateLiveRuntime(): void { 583 | this._liveRuntimeSeconds = 0; 584 | } 585 | 586 | _stopLiveRuntime(): void { 587 | this._liveRuntimeSeconds = 0; 588 | } 589 | 590 | _updateCountdown(): void { 591 | if (!this._entitiesLoaded || !this.hass || !this.hass.states) { 592 | this._stopCountdown(); 593 | return; 594 | } 595 | const sensor = this.hass.states[this._effectiveSensorEntity!]; 596 | 597 | if (!sensor || sensor.attributes.timer_state !== 'active') { 598 | this._stopCountdown(); 599 | this._notificationSentForCurrentCycle = false; 600 | return; 601 | } 602 | 603 | if (!this._countdownInterval) { 604 | const rawFinish = sensor.attributes.timer_finishes_at; 605 | if (rawFinish === undefined) { 606 | console.warn("Timer-card: timer_finishes_at is undefined for active timer. Stopping countdown."); 607 | this._stopCountdown(); 608 | return; 609 | } 610 | const finishesAt = new Date(rawFinish).getTime(); 611 | 612 | const update = () => { 613 | const now = new Date().getTime(); 614 | const remaining = Math.max(0, Math.round((finishesAt - now) / 1000)); 615 | 616 | // Format countdown based on show_seconds setting 617 | const showSeconds = this._getShowSeconds(); 618 | if (showSeconds) { 619 | const hours = Math.floor(remaining / 3600); 620 | const minutes = Math.floor((remaining % 3600) / 60); 621 | const seconds = remaining % 60; 622 | this._timeRemaining = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 623 | } else { 624 | const minutes = Math.floor(remaining / 60); 625 | const seconds = remaining % 60; 626 | this._timeRemaining = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 627 | } 628 | 629 | if (remaining === 0) { 630 | this._stopCountdown(); 631 | if (!this._notificationSentForCurrentCycle) { 632 | this._notificationSentForCurrentCycle = true; 633 | } 634 | } 635 | }; 636 | this._countdownInterval = window.setInterval(update, 500); 637 | update(); 638 | } 639 | } 640 | 641 | _stopCountdown(): void { 642 | if (this._countdownInterval) { 643 | window.clearInterval(this._countdownInterval); 644 | this._countdownInterval = null; 645 | } 646 | this._timeRemaining = null; 647 | } 648 | 649 | _hasOrphanedTimer(): { isOrphaned: boolean; duration?: number } { 650 | if (!this._entitiesLoaded || !this.hass || !this._effectiveSensorEntity) { 651 | return { isOrphaned: false }; 652 | } 653 | 654 | const sensor = this.hass.states[this._effectiveSensorEntity]; 655 | if (!sensor || sensor.attributes.timer_state !== 'active') { 656 | return { isOrphaned: false }; 657 | } 658 | 659 | const activeDuration = sensor.attributes.timer_duration || 0; 660 | const startMethod = sensor.attributes.timer_start_method; 661 | 662 | // If started by slider, it is not orphaned 663 | if (startMethod === 'slider') { 664 | return { isOrphaned: false }; 665 | } 666 | 667 | const hasMatchingButton = this.buttons.some(b => Math.abs(b.minutesEquivalent - activeDuration) < 0.001); 668 | 669 | let sliderMax = this._config?.slider_max || 120; 670 | const sliderUnit = this._config?.slider_unit || 'min'; 671 | 672 | // Convert slider max to minutes for comparison with active duration 673 | if (sliderUnit === 'h' || sliderUnit === 'hr' || sliderUnit === 'hours') { 674 | sliderMax = sliderMax * 60; 675 | } else if (sliderUnit === 's' || sliderUnit === 'sec' || sliderUnit === 'seconds') { 676 | sliderMax = sliderMax / 60; 677 | } 678 | 679 | // Add epsilon for float safety 680 | const isWithinSliderRange = activeDuration >= 0 && activeDuration <= (sliderMax + 0.001); 681 | 682 | return { 683 | isOrphaned: !hasMatchingButton && !isWithinSliderRange, 684 | duration: activeDuration 685 | }; 686 | } 687 | 688 | // Get show_seconds from the sensor attributes (backend config) 689 | _getShowSeconds(): boolean { 690 | if (!this._entitiesLoaded || !this.hass || !this._effectiveSensorEntity) { 691 | return false; 692 | } 693 | 694 | const sensor = this.hass.states[this._effectiveSensorEntity]; 695 | // The backend will set this attribute based on the config entry 696 | return sensor?.attributes?.show_seconds || false; 697 | } 698 | 699 | _handleUsageClick(event: Event): void { 700 | // Prevent default to avoid conflicts with touch events 701 | event.preventDefault(); 702 | // Only show more info if it wasn't a long press 703 | if (!this._isLongPress) { 704 | this._showMoreInfo(); 705 | } 706 | this._isLongPress = false; 707 | } 708 | 709 | _startLongPress(event: Event): void { 710 | event.preventDefault(); 711 | this._isLongPress = false; 712 | 713 | this._longPressTimer = window.setTimeout(() => { 714 | this._isLongPress = true; 715 | this._resetUsage(); 716 | // Add haptic feedback on mobile 717 | if ('vibrate' in navigator) { 718 | navigator.vibrate(50); 719 | } 720 | }, 800); // 800ms long press duration 721 | } 722 | 723 | _endLongPress(event?: Event): void { 724 | if (event) { 725 | event.preventDefault(); 726 | } 727 | if (this._longPressTimer) { 728 | window.clearTimeout(this._longPressTimer); 729 | this._longPressTimer = null; 730 | } 731 | } 732 | 733 | _handlePowerClick(event: Event): void { 734 | // Only handle mouse clicks, not touch events 735 | if (event.type === 'click' && !this._isLongPress) { 736 | event.preventDefault(); 737 | event.stopPropagation(); 738 | this._togglePower(); 739 | } 740 | this._isLongPress = false; 741 | } 742 | 743 | _handleTouchEnd(event: TouchEvent): void { 744 | event.preventDefault(); 745 | event.stopPropagation(); 746 | 747 | if (this._longPressTimer) { 748 | window.clearTimeout(this._longPressTimer); 749 | this._longPressTimer = null; 750 | } 751 | 752 | // Check if the touch moved too much (sliding) 753 | let hasMoved = false; 754 | if (this._touchStartPosition && event.changedTouches[0]) { 755 | const touch = event.changedTouches[0]; 756 | const deltaX = Math.abs(touch.clientX - this._touchStartPosition.x); 757 | const deltaY = Math.abs(touch.clientY - this._touchStartPosition.y); 758 | const moveThreshold = 10; // pixels 759 | 760 | hasMoved = deltaX > moveThreshold || deltaY > moveThreshold; 761 | } 762 | 763 | // Only trigger if it's not a long press AND the touch didn't move much 764 | if (!this._isLongPress && !hasMoved) { 765 | this._showMoreInfo(); 766 | } 767 | 768 | this._isLongPress = false; 769 | this._touchStartPosition = null; 770 | } 771 | 772 | _handleTouchStart(event: TouchEvent): void { 773 | event.preventDefault(); 774 | event.stopPropagation(); 775 | this._isLongPress = false; 776 | 777 | // Record the initial touch position 778 | const touch = event.touches[0]; 779 | this._touchStartPosition = { x: touch.clientX, y: touch.clientY }; 780 | 781 | this._longPressTimer = window.setTimeout(() => { 782 | this._isLongPress = true; 783 | this._resetUsage(); 784 | if ('vibrate' in navigator) { 785 | navigator.vibrate(50); 786 | } 787 | }, 800); 788 | } 789 | 790 | _resetUsage(): void { 791 | this._validationMessages = []; 792 | 793 | if (!this._entitiesLoaded || !this.hass || !this.hass.callService) { 794 | console.error("Timer-card: Cannot reset usage. Entities not loaded or callService unavailable."); 795 | return; 796 | } 797 | 798 | const entryId = this._getEntryId(); 799 | if (!entryId) { 800 | console.error("Timer-card: Entry ID not found for resetting usage."); 801 | return; 802 | } 803 | 804 | // Show confirmation dialog 805 | if (!confirm("Reset daily usage to 00:00?\n\nThis action cannot be undone.")) { 806 | return; 807 | } 808 | 809 | this.hass.callService(DOMAIN, "reset_daily_usage", { entry_id: entryId }) 810 | .then(() => { 811 | console.log("Timer-card: Daily usage reset successfully"); 812 | }) 813 | .catch(error => { 814 | console.error("Timer-card: Error resetting daily usage:", error); 815 | }); 816 | } 817 | 818 | _handleSliderChange(event: Event): void { 819 | const slider = event.target as HTMLInputElement; 820 | this._sliderValue = parseInt(slider.value); 821 | 822 | const instanceId = this._config?.timer_instance_id || 'default'; 823 | localStorage.setItem(`simple-timer-slider-${instanceId}`, this._sliderValue.toString()); 824 | } 825 | 826 | _getCurrentTimerMode(): string { 827 | if (!this._entitiesLoaded || !this.hass || !this._effectiveSensorEntity) { 828 | return 'normal'; 829 | } 830 | 831 | const sensor = this.hass.states[this._effectiveSensorEntity]; 832 | return sensor?.attributes?.reverse_mode ? 'reverse' : 'normal'; 833 | } 834 | 835 | _getSliderStyle(): string { 836 | const thumbColor = this._config?.slider_thumb_color || '#2ab69c'; 837 | const backgroundColor = this._config?.slider_background_color || 'var(--secondary-background-color)'; 838 | const borderColor = this._config?.slider_thumb_color ? 839 | this._adjustColorBrightness(thumbColor, 20) : '#4bd9bf'; 840 | 841 | // Convert hex to RGB for rgba() usage in box-shadow 842 | const hexToRgb = (hex: string) => { 843 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 844 | return result ? { 845 | r: parseInt(result[1], 16), 846 | g: parseInt(result[2], 16), 847 | b: parseInt(result[3], 16) 848 | } : { r: 42, g: 182, b: 156 }; // fallback to default 849 | }; 850 | 851 | const rgb = hexToRgb(thumbColor); 852 | const borderRgb = hexToRgb(borderColor); 853 | 854 | return ` 855 | .timer-slider { 856 | background: ${backgroundColor} !important; 857 | } 858 | .timer-slider::-webkit-slider-thumb { 859 | background: ${thumbColor} !important; 860 | border: 2px solid ${borderColor} !important; 861 | box-shadow: 862 | 0 0 0 2px rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.3), 863 | 0 0 8px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4), 864 | 0 2px 4px rgba(0, 0, 0, 0.2) !important; 865 | } 866 | .timer-slider::-webkit-slider-thumb:hover { 867 | background: ${this._adjustColorBrightness(thumbColor, -10)} !important; 868 | border: 2px solid ${borderColor} !important; 869 | box-shadow: 870 | 0 0 0 3px rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.4), 871 | 0 0 12px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.6), 872 | 0 2px 6px rgba(0, 0, 0, 0.3) !important; 873 | } 874 | .timer-slider::-webkit-slider-thumb:active { 875 | background: ${this._adjustColorBrightness(thumbColor, -20)} !important; 876 | border: 2px solid ${borderColor} !important; 877 | box-shadow: 878 | 0 0 0 4px rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.5), 879 | 0 0 16px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7), 880 | 0 2px 8px rgba(0, 0, 0, 0.4) !important; 881 | } 882 | .timer-slider::-moz-range-thumb { 883 | background: ${thumbColor} !important; 884 | border: 2px solid ${borderColor} !important; 885 | box-shadow: 886 | 0 0 0 2px rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.3), 887 | 0 0 8px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4), 888 | 0 2px 4px rgba(0, 0, 0, 0.2) !important; 889 | } 890 | .timer-slider::-moz-range-thumb:hover { 891 | background: ${this._adjustColorBrightness(thumbColor, -10)} !important; 892 | border: 2px solid ${borderColor} !important; 893 | box-shadow: 894 | 0 0 0 3px rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.4), 895 | 0 0 12px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.6), 896 | 0 2px 6px rgba(0, 0, 0, 0.3) !important; 897 | } 898 | .timer-slider::-moz-range-thumb:active { 899 | background: ${this._adjustColorBrightness(thumbColor, -20)} !important; 900 | border: 2px solid ${borderColor} !important; 901 | box-shadow: 902 | 0 0 0 4px rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.5), 903 | 0 0 16px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7), 904 | 0 2px 8px rgba(0, 0, 0, 0.4) !important; 905 | } 906 | `; 907 | } 908 | 909 | _getTimerButtonStyle(): string { 910 | const fontColor = this._config?.timer_button_font_color; 911 | const backgroundColor = this._config?.timer_button_background_color; 912 | 913 | if (!fontColor && !backgroundColor) { 914 | return ''; // No custom styling needed 915 | } 916 | 917 | let styles = ''; 918 | 919 | if (fontColor || backgroundColor) { 920 | styles += ` 921 | .timer-button { 922 | ${fontColor ? `color: ${fontColor} !important;` : ''} 923 | ${backgroundColor ? `background-color: ${backgroundColor} !important;` : ''} 924 | } 925 | `; 926 | } 927 | 928 | return styles; 929 | } 930 | 931 | _getPowerButtonStyle(): string { 932 | const backgroundColor = this._config?.power_button_background_color; 933 | const iconColor = this._config?.power_button_icon_color; 934 | 935 | if (!backgroundColor && !iconColor) { 936 | return ''; // No custom styling needed 937 | } 938 | 939 | let styles = ''; 940 | 941 | if (backgroundColor || iconColor) { 942 | styles += ` 943 | .power-button-small, .power-button-top-right { 944 | ${backgroundColor ? `background-color: ${backgroundColor} !important;` : ''} 945 | } 946 | 947 | .power-button-small ha-icon[icon], .power-button-top-right ha-icon[icon] { 948 | ${iconColor ? `color: ${iconColor} !important;` : ''} 949 | } 950 | 951 | .power-button-small.reverse ha-icon[icon], .power-button-top-right.reverse ha-icon[icon] { 952 | ${iconColor ? `color: ${iconColor} !important;` : ''} 953 | } 954 | `; 955 | } 956 | 957 | return styles; 958 | } 959 | 960 | _adjustColorBrightness(color: string, percent: number): string { 961 | const num = parseInt(color.replace("#", ""), 16); 962 | const amt = Math.round(2.55 * percent); 963 | const R = Math.max(0, Math.min(255, (num >> 16) + amt)); 964 | const G = Math.max(0, Math.min(255, (num >> 8 & 0x00FF) + amt)); 965 | const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt)); 966 | return "#" + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1); 967 | } 968 | 969 | render() { 970 | let message: string | null = null; 971 | let isWarning = false; 972 | 973 | if (!this.hass) { 974 | message = "Home Assistant object (hass) not available. Card cannot load."; 975 | isWarning = true; 976 | } else if (!this._entitiesLoaded) { 977 | if (this._config?.timer_instance_id) { 978 | const configuredSensorState = Object.values(this.hass.states).find( 979 | (state: HAState) => state.attributes.entry_id === this._config!.timer_instance_id && state.entity_id.startsWith('sensor.') 980 | ) as HAState | undefined; 981 | 982 | if (!configuredSensorState) { 983 | message = `Timer Control Instance '${this._config.timer_instance_id}' not found. Please select a valid instance in the card editor.`; 984 | isWarning = true; 985 | } else if (typeof configuredSensorState.attributes.switch_entity_id !== 'string' || !(configuredSensorState.attributes.switch_entity_id && this.hass.states[configuredSensorState.attributes.switch_entity_id])) { 986 | message = `Timer Control Instance '${this._config.timer_instance_id}' linked to missing or invalid switch '${configuredSensorState.attributes.switch_entity_id}'. Please check instance configuration.`; 987 | isWarning = true; 988 | } else { 989 | message = "Loading Timer Control Card. Please wait..."; 990 | isWarning = false; 991 | } 992 | } else if (this._config?.sensor_entity) { 993 | const configuredSensorState = this.hass.states[this._config.sensor_entity]; 994 | if (!configuredSensorState) { 995 | message = `Configured Timer Control Sensor '${this._config.sensor_entity}' not found. Please select a valid instance in the card editor.`; 996 | isWarning = true; 997 | } else if (typeof configuredSensorState.attributes.switch_entity_id !== 'string' || !(configuredSensorState.attributes.switch_entity_id && this.hass.states[configuredSensorState.attributes.switch_entity_id])) { 998 | message = `Configured Timer Control Sensor '${this._config.sensor_entity}' is invalid or its linked switch '${configuredSensorState.attributes.switch_entity_id}' is missing. Please select a valid instance.`; 999 | isWarning = true; 1000 | } else { 1001 | message = "Loading Timer Control Card. Please wait..."; 1002 | isWarning = false; 1003 | } 1004 | } else { 1005 | message = "Select a Timer Control Instance from the dropdown in the card editor to link this card."; 1006 | isWarning = false; 1007 | } 1008 | } 1009 | 1010 | if (message) { 1011 | return html`
${message}
`; 1012 | } 1013 | 1014 | const timerSwitch = this.hass!.states[this._effectiveSwitchEntity!]; 1015 | const sensor = this.hass!.states[this._effectiveSensorEntity!]; 1016 | 1017 | const isOn = timerSwitch.state === 'on'; 1018 | const isTimerActive = sensor.attributes.timer_state === 'active'; 1019 | const timerDurationInMinutes = sensor.attributes.timer_duration || 0; 1020 | const isManualOn = isOn && !isTimerActive; 1021 | const isReverseMode = sensor.attributes.reverse_mode; 1022 | 1023 | const committedSeconds = parseFloat(sensor.state as string) || 0; 1024 | 1025 | // Format time based on show_seconds setting from backend 1026 | const showSeconds = this._getShowSeconds(); 1027 | let dailyUsageFormatted: string; 1028 | let countdownDisplay: string; 1029 | 1030 | if (showSeconds) { 1031 | // Show full HH:MM:SS format 1032 | const totalSecondsInt = Math.floor(committedSeconds); 1033 | const hours = Math.floor(totalSecondsInt / 3600); 1034 | const minutes = Math.floor((totalSecondsInt % 3600) / 60); 1035 | const seconds = totalSecondsInt % 60; 1036 | dailyUsageFormatted = `Daily usage: ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 1037 | 1038 | // Countdown display - show active countdown or 00:00:00 1039 | countdownDisplay = this._timeRemaining || '00:00:00'; 1040 | } else { 1041 | // Show HH:MM format (original behavior) 1042 | const totalMinutes = Math.floor(committedSeconds / 60); 1043 | const hours = Math.floor(totalMinutes / 60); 1044 | const minutes = totalMinutes % 60; 1045 | dailyUsageFormatted = `Daily usage: ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; 1046 | 1047 | // Countdown display - show active countdown or 00:00 1048 | countdownDisplay = this._timeRemaining || '00:00'; 1049 | } 1050 | 1051 | const watchdogMessage = sensor.attributes.watchdog_message; 1052 | const orphanedTimer = this._hasOrphanedTimer(); 1053 | 1054 | return html` 1055 | 1060 | 1061 |
1062 |
${this._config?.card_title || ''}
1063 |
1064 | 1065 | ${watchdogMessage ? html` 1066 |
1067 | 1068 | ${watchdogMessage} 1069 |
1070 | ` : ''} 1071 | ${orphanedTimer.isOrphaned ? html` 1072 |
1073 | 1074 | 1075 | Active ${orphanedTimer.duration}-minute timer has no corresponding button. 1076 | Use the power button to cancel or wait for automatic completion. 1077 | 1078 |
1079 | ` : ''} 1080 | 1081 |
1082 | 1083 | ${this._config?.hide_slider ? html` 1084 |
1087 | ${this._config?.power_button_icon ? html`` : ''} 1088 |
1089 | ` : ''} 1090 | 1091 | 1092 |
1093 |
1094 | ${countdownDisplay} 1095 |
1096 | ${this._config?.show_daily_usage !== false ? html` 1097 |
1106 | ${dailyUsageFormatted} 1107 |
1108 | ` : ''} 1109 |
1110 | 1111 | 1112 | ${!this._config?.hide_slider ? html` 1113 |
1114 |
1115 | 1124 | ${this._sliderValue} ${this._config?.slider_unit || 'min'} 1125 |
1126 | 1127 |
1130 | ${this._config?.power_button_icon ? html`` : ''} 1131 |
1132 |
1133 | ` : ''} 1134 | 1135 | 1136 |
1137 | ${this.buttons.map(button => { 1138 | // Only highlight if timer was started via button, NOT slider 1139 | // Use small epsilon for float comparison (minutes internal storage) 1140 | const isActive = isTimerActive && Math.abs(timerDurationInMinutes - button.minutesEquivalent) < 0.001 && sensor.attributes.timer_start_method === 'button'; 1141 | const isDisabled = isManualOn || (isTimerActive && !isActive); 1142 | return html` 1143 |
{ 1145 | if (isActive) this._cancelTimer(); 1146 | else if (!isDisabled) { 1147 | this._startTimer(button.displayValue, button.unit, 'button'); 1148 | } 1149 | }}> 1150 |
${button.displayValue}
1151 |
${button.labelUnit}
1152 |
1153 | `; 1154 | })} 1155 |
1156 |
1157 | 1158 | ${this._validationMessages.length > 0 ? html` 1159 |
1160 | 1161 |
1162 | ${this._validationMessages.map(msg => html`
${msg}
`)} 1163 |
1164 |
1165 | ` : ''} 1166 |
1167 | `; 1168 | } 1169 | 1170 | static get styles() { 1171 | return cardStyles; 1172 | } 1173 | } 1174 | customElements.define("timer-card", TimerCard); 1175 | 1176 | window.customCards = window.customCards || []; 1177 | window.customCards.push({ 1178 | type: "timer-card", 1179 | name: "Simple Timer Card", 1180 | description: "A card for the Simple Timer integration.", 1181 | }); --------------------------------------------------------------------------------