├── .gitignore ├── hacs.json ├── src ├── declarations.d.ts ├── translations │ ├── en.json │ └── uk.json ├── config.ts ├── styles.css ├── localize.ts ├── types.ts ├── power-outage-schedule-card.ts └── resources.ts ├── images ├── POS.gif ├── notification_schedule_added.png └── notification_schedule_changed.png ├── tsconfig.json ├── LICENSE ├── package.json ├── rollup.config.js ├── notifications ├── README.md └── scripts.yaml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Power outage schedule card", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | // declaration.d.ts 2 | declare module '*.css'; 3 | declare module '*.svg'; -------------------------------------------------------------------------------- /images/POS.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/power-outage-schedule-card/HEAD/images/POS.gif -------------------------------------------------------------------------------- /images/notification_schedule_added.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/power-outage-schedule-card/HEAD/images/notification_schedule_added.png -------------------------------------------------------------------------------- /images/notification_schedule_changed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/power-outage-schedule-card/HEAD/images/notification_schedule_changed.png -------------------------------------------------------------------------------- /src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "invalid_config": "Invalid configuration", 4 | "missing_entity": "Incorrect entity" 5 | }, 6 | "common": { 7 | "yesterday": "Yesterday", 8 | "today": "Today", 9 | "tomorrow": "Tomorrow", 10 | "approved_since": "Approved since" 11 | } 12 | } -------------------------------------------------------------------------------- /src/translations/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "invalid_config": "Неправильна конфігурація", 4 | "missing_entity": "Необхідно вказати сутність" 5 | }, 6 | "common": { 7 | "yesterday": "Вчора", 8 | "today": "Сьогодні", 9 | "tomorrow": "Завтра", 10 | "approved_since": "Затверджено" 11 | } 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "experimentalDecorators": true, 11 | "useDefineForClassFields": false, 12 | "resolveJsonModule": true, 13 | } 14 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import localize from './localize'; 2 | import { 3 | PowerOutageScheduleCardConfig, 4 | } from './types' 5 | 6 | export default function buildConfig( 7 | config?: Partial 8 | ): PowerOutageScheduleCardConfig { 9 | if (!config) { 10 | throw new Error(localize('error.invalid_config')); 11 | } 12 | 13 | if (!config.queue_entity || !config.today_entity || !config.tomorrow_entity) { 14 | throw new Error(localize('error.missing_entity')); 15 | } 16 | 17 | return { 18 | queue_entity: config.queue_entity, 19 | today_entity: config.today_entity, 20 | tomorrow_entity: config.tomorrow_entity, 21 | title: config.title ?? '', 22 | empty_text: config.empty_text ?? 'No data yet', 23 | hide_past_hours: config.hide_past_hours ?? true, 24 | reload_action: config.reload_action, 25 | }; 26 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 strange_v 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | :host { 2 | --vc-background: var(--ha-card-background, var(--card-background-color, white)); 3 | --vc-spacing: 10px; 4 | --primary-color: #89B3F8; 5 | --light-theme-background: #fff; 6 | --dark-theme-background: #1d1d1d; 7 | } 8 | 9 | ha-card { 10 | background-color: var(--vc-background); 11 | 12 | .header { 13 | padding: var(--vc-spacing); 14 | text-align: center; 15 | } 16 | 17 | .schedule { 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | 23 | &.empty { 24 | min-height: 100px; 25 | padding: 0px 15%; 26 | font-size: 1.25em; 27 | text-align: center; 28 | line-height: 1.5em; 29 | } 30 | 31 | } 32 | 33 | .footer { 34 | display: flex; 35 | box-sizing: border-box; 36 | padding: var(--vc-spacing); 37 | width: 100%; 38 | } 39 | 40 | .actions { 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | } 45 | 46 | .approved { 47 | opacity: .5; 48 | text-align: right; 49 | flex: 1 50 | } 51 | 52 | .action-reload { 53 | cursor: pointer; 54 | } 55 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "power-outage-schedule-card", 3 | "version": "1.3.2", 4 | "description": "Power outage schedule card for Home Assistant", 5 | "main": "dist/power-outage-schedule-card.js", 6 | "scripts": { 7 | "start": "rollup -c --watch", 8 | "build": "rollup -c" 9 | }, 10 | "author": "strange_v", 11 | "license": "MIT", 12 | "type": "module", 13 | "devDependencies": { 14 | "@rollup/plugin-babel": "^6.0.4", 15 | "@rollup/plugin-commonjs": "^28.0.0", 16 | "@rollup/plugin-image": "^3.0.3", 17 | "@rollup/plugin-json": "^6.1.0", 18 | "@rollup/plugin-node-resolve": "^15.3.0", 19 | "@rollup/plugin-replace": "^6.0.1", 20 | "postcss": "^8.4.47", 21 | "postcss-preset-env": "^10.0.5", 22 | "rollup-plugin-minify-html-literals": "^1.2.6", 23 | "rollup-plugin-postcss": "^4.0.2", 24 | "rollup-plugin-postcss-lit": "^2.1.0", 25 | "rollup-plugin-serve": "^1.1.1", 26 | "rollup-plugin-terser": "^7.0.2", 27 | "rollup-plugin-typescript2": "^0.36.0", 28 | "tslib": "^2.7.0", 29 | "typescript": "^5.6.2" 30 | }, 31 | "dependencies": { 32 | "custom-card-helpers": "^1.9.0", 33 | "home-assistant-js-websocket": "^9.4.0", 34 | "lit": "^3.2.0", 35 | "swiper": "^11.1.14" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/localize.ts: -------------------------------------------------------------------------------- 1 | // Borrowed from: 2 | // https://github.com/custom-cards/boilerplate-card/blob/master/src/localize/localize.ts 3 | 4 | // Sorted alphabetically 5 | import * as en from './translations/en.json'; 6 | import * as uk from './translations/uk.json'; 7 | 8 | type Translations = { 9 | [key: string]: { 10 | [key: string]: string; 11 | }; 12 | }; 13 | 14 | const languages: Record = { 15 | en, 16 | uk, 17 | }; 18 | 19 | const DEFAULT_LANG = 'en'; 20 | 21 | export default function localize( 22 | str: string, 23 | search?: string, 24 | replace?: string 25 | ): string | undefined { 26 | const [section, key] = str.toLowerCase().split('.'); 27 | 28 | let langStored: string | null = null; 29 | 30 | try { 31 | langStored = JSON.parse(localStorage.getItem('selectedLanguage') ?? ''); 32 | } catch (e) { 33 | langStored = localStorage.getItem('selectedLanguage'); 34 | } 35 | 36 | const lang = (langStored || navigator.language.split('-')[0] || DEFAULT_LANG) 37 | .replace(/['"]+/g, '') 38 | .replace('-', '_'); 39 | 40 | let translated: string | undefined; 41 | 42 | try { 43 | translated = languages[lang][section][key]; 44 | } catch (e) { 45 | translated = languages[DEFAULT_LANG][section][key]; 46 | } 47 | 48 | if (translated === undefined) { 49 | translated = key; 50 | } 51 | 52 | if (translated === undefined) { 53 | return; 54 | } 55 | 56 | if (search && replace) { 57 | translated = translated?.replace(search, replace); 58 | } 59 | 60 | return translated; 61 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HassServiceTarget, 3 | } from 'home-assistant-js-websocket'; 4 | import { HomeAssistant } from 'custom-card-helpers'; 5 | import { TemplateResult, nothing } from 'lit'; 6 | 7 | export type TemplateNothing = typeof nothing; 8 | export type Template = TemplateResult | TemplateNothing; 9 | 10 | export interface Theme { 11 | "primary-color": string; 12 | "text-primary-color": string; 13 | "accent-color": string; 14 | } 15 | export interface Themes { 16 | default_theme: string; 17 | darkMode: boolean; 18 | themes: { 19 | [key: string]: Theme; 20 | }; 21 | } 22 | export interface Area { 23 | area_id: string; 24 | icon?: string; 25 | name?: string; 26 | } 27 | export interface MyHomeAssistant extends HomeAssistant { 28 | areas: Record; 29 | themes: Themes 30 | } 31 | 32 | export interface PowerOutageCardAction { 33 | service: string; 34 | service_data?: Record; 35 | target?: HassServiceTarget; 36 | } 37 | 38 | export interface PowerOutageScheduleCardConfig { 39 | title: string; 40 | empty_text: string; 41 | hide_past_hours: boolean; 42 | queue_entity: string; 43 | today_entity: string; 44 | tomorrow_entity: string; 45 | reload_action?: PowerOutageCardAction; 46 | } 47 | 48 | export interface Time { 49 | hour: number; 50 | minute: number; 51 | } 52 | export interface PowerOutagePeriod { 53 | from: Time; 54 | to: Time; 55 | state: number; 56 | } 57 | export interface PowerOutageSchedule { 58 | eventDate: string; 59 | scheduleApprovedSince: string; 60 | periods: PowerOutagePeriod[]; 61 | } 62 | 63 | export interface ScheduleGraphColors { 64 | background: string; 65 | text: string; 66 | green: string; 67 | green_past: string; 68 | red: string; 69 | red_past: string; 70 | yellow: string; 71 | yellow_past: string; 72 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import nodeResolve from '@rollup/plugin-node-resolve'; 4 | import json from '@rollup/plugin-json'; 5 | import typescript from 'rollup-plugin-typescript2'; 6 | import babel from '@rollup/plugin-babel'; 7 | import postcss from 'rollup-plugin-postcss'; 8 | import postcssPresetEnv from 'postcss-preset-env'; 9 | import postcssLit from 'rollup-plugin-postcss-lit'; 10 | import { terser } from 'rollup-plugin-terser'; 11 | import minifyLiterals from 'rollup-plugin-minify-html-literals'; 12 | import image from '@rollup/plugin-image'; 13 | import replace from '@rollup/plugin-replace'; 14 | import serve from 'rollup-plugin-serve' 15 | 16 | const IS_DEV = process.env.ROLLUP_WATCH; 17 | 18 | const serverOptions = { 19 | contentBase: ['./dist'], 20 | host: '0.0.0.0', 21 | port: 5000, 22 | allowCrossOrigin: true, 23 | headers: { 24 | 'Access-Control-Allow-Origin': '*', 25 | }, 26 | }; 27 | 28 | const plugins = [ 29 | nodeResolve(), 30 | commonjs(), 31 | json(), 32 | replace({ 33 | values: { 34 | PKG_VERSION_VALUE: IS_DEV ? 'DEVELOPMENT' : pkg.version, 35 | }, 36 | preventAssignment: true, 37 | }), 38 | postcss({ 39 | plugins: [ 40 | postcssPresetEnv({ 41 | stage: 1, 42 | features: { 43 | 'nesting-rules': true, 44 | }, 45 | }), 46 | ], 47 | extract: false, 48 | }), 49 | postcssLit(), 50 | image(), 51 | typescript(), 52 | babel({ 53 | babelHelpers: 'runtime', 54 | exclude: 'node_modules/**', 55 | }), 56 | IS_DEV && serve(serverOptions), 57 | !IS_DEV && minifyLiterals(), 58 | !IS_DEV && 59 | terser({ 60 | output: { 61 | comments: false, 62 | }, 63 | }), 64 | ]; 65 | 66 | export default { 67 | input: 'src/power-outage-schedule-card.ts', 68 | output: { 69 | dir: 'dist', 70 | format: 'es', 71 | inlineDynamicImports: true, 72 | }, 73 | context: 'window', 74 | plugins, 75 | }; -------------------------------------------------------------------------------- /notifications/README.md: -------------------------------------------------------------------------------- 1 | # Notifications about schedule added or changed 2 | 3 | I like getting updates about changes in the power outage schedule, so I can update my plans accordingly. There are two types of notifications: 4 | 5 | - Schedule added 6 | - Schedule changes 7 | 8 | Each notification consists of automation and script. 9 | 10 | ## Schedule added automation 11 | 12 | ```yaml 13 | alias: Power outage schedule added 14 | description: Notifies when a power schedule for today or tomorrow is added 15 | triggers: 16 | - entity_id: 17 | - sensor.oe_today 18 | to: null 19 | id: today 20 | for: 21 | hours: 0 22 | minutes: 0 23 | seconds: 5 24 | from: unknown 25 | trigger: state 26 | - entity_id: 27 | - sensor.oe_tomorrow 28 | to: null 29 | id: tomorrow 30 | for: 31 | hours: 0 32 | minutes: 0 33 | seconds: 5 34 | from: unknown 35 | trigger: state 36 | conditions: [] 37 | actions: 38 | - choose: 39 | - conditions: 40 | - condition: trigger 41 | id: 42 | - today 43 | sequence: 44 | - data: 45 | day: today 46 | current: "{{ trigger.to_state.state }}" 47 | previous: "{{ trigger.from_state.state }}" 48 | action: script.notify_power_outage_schedule_added 49 | - conditions: 50 | - condition: trigger 51 | id: 52 | - tomorrow 53 | sequence: 54 | - data: 55 | day: tomorrow 56 | current: "{{ trigger.to_state.state }}" 57 | previous: "{{ trigger.from_state.state }}" 58 | action: script.notify_power_outage_schedule_added 59 | mode: single 60 | ``` 61 | 62 | ## Schedule changed automation 63 | 64 | ```yaml 65 | alias: Power outage schedule changed 66 | description: Notifies when an existing schedule for today or tomorrow changes 67 | triggers: 68 | - entity_id: 69 | - sensor.oe_today 70 | to: null 71 | id: today 72 | for: 73 | hours: 0 74 | minutes: 0 75 | seconds: 5 76 | trigger: state 77 | - entity_id: 78 | - sensor.oe_tomorrow 79 | to: null 80 | id: tomorrow 81 | for: 82 | hours: 0 83 | minutes: 0 84 | seconds: 5 85 | trigger: state 86 | conditions: [] 87 | actions: 88 | - choose: 89 | - conditions: 90 | - condition: trigger 91 | id: 92 | - today 93 | sequence: 94 | - data: 95 | day: today 96 | current: "{{ trigger.to_state.state }}" 97 | previous: "{{ trigger.from_state.state }}" 98 | action: script.notify_power_outage_schedule_changed 99 | - conditions: 100 | - condition: trigger 101 | id: 102 | - tomorrow 103 | sequence: 104 | - data: 105 | day: tomorrow 106 | current: "{{ trigger.to_state.state }}" 107 | previous: "{{ trigger.from_state.state }}" 108 | action: script.notify_power_outage_schedule_changed 109 | mode: single 110 | ``` 111 | 112 | ### Scripts 113 | 114 | All needed scripts are in [scripts.yaml](scripts.yaml). 115 | -------------------------------------------------------------------------------- /src/power-outage-schedule-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, CSSResultGroup, html, nothing } from 'lit'; 2 | import { customElement, property, state } from 'lit/decorators.js'; 3 | import { register } from 'swiper/element/bundle'; 4 | import buildConfig from './config'; 5 | import localize from './localize'; 6 | import styles from './styles.css'; 7 | import { 8 | Template, 9 | MyHomeAssistant, 10 | PowerOutageScheduleCardConfig, 11 | PowerOutageSchedule, 12 | ScheduleGraphColors, 13 | PowerOutageCardAction, 14 | } from './types' 15 | import { 16 | getScheduleGraph, 17 | } from './resources' 18 | 19 | register(); 20 | 21 | @customElement('power-outage-schedule-card') 22 | export class PowerOutageScheduleCard extends LitElement { 23 | @property({ attribute: false }) 24 | public hass!: MyHomeAssistant; 25 | @state() 26 | private config!: PowerOutageScheduleCardConfig; 27 | 28 | static get styles(): CSSResultGroup { 29 | return styles; 30 | } 31 | 32 | setConfig(config: PowerOutageScheduleCardConfig) { 33 | this.config = buildConfig(config); 34 | } 35 | 36 | getCardSize(): Number { 37 | return 3; 38 | } 39 | 40 | protected render(): Template { 41 | if (!this.hass || !this.config) 42 | return nothing; 43 | 44 | const colors = this.getGraphColors(); 45 | const queue = this.state(this.config.queue_entity); 46 | 47 | const header = this.config.title 48 | ? html`
${this.config.title}
` 49 | : nothing; 50 | const today = this.getSchedule(queue, this.config.today_entity, colors); 51 | const tomorrow = this.getSchedule(queue, this.config.tomorrow_entity, colors); 52 | 53 | return html` 54 | 55 | ${header} 56 | 57 | ${today} 58 | ${tomorrow} 59 | 60 | 61 | `; 62 | } 63 | 64 | private getSchedule(queue: string, id: string, colors: ScheduleGraphColors): Template { 65 | const data = this.getQueueData(id); 66 | const day = this.getRelativeDate(data.eventDate); 67 | const dayLocal = localize(`common.${day}`); 68 | const hidePast = this.config.hide_past_hours && day == 'Today'; 69 | const graph = getScheduleGraph(queue, dayLocal!, data.periods, hidePast, colors); 70 | const reload = this.getReloadIcon(); 71 | 72 | if (!data.scheduleApprovedSince) { 73 | return html` 74 | 75 |
76 | ${this.config.empty_text} 77 | ${reload} 78 |
79 |
80 | `; 81 | } 82 | 83 | const approvedSince = localize('common.approved_since'); 84 | 85 | return html` 86 | 87 |
88 | ${graph} 89 | 93 |
94 |
95 | `; 96 | } 97 | 98 | private getQueueData(id: string): PowerOutageSchedule { 99 | const data: PowerOutageSchedule = { 100 | eventDate: '', 101 | scheduleApprovedSince: '', 102 | periods: [] 103 | }; 104 | const state = this.state(id); 105 | const values = state.split(';'); 106 | 107 | if (values.length < 3) 108 | return data; 109 | 110 | data.eventDate = values.shift()!; 111 | data.scheduleApprovedSince = values.shift()!; 112 | 113 | values.forEach((period, i) => { 114 | if (!period) 115 | return; 116 | 117 | const segments = period.split('-'); 118 | const [fromHour, fromMinute] = segments.shift()!.split(':').map(v => Number(v)); 119 | const [toHour, toMinute] = segments.shift()!.split(':').map(v => Number(v)); 120 | const state = Number(segments.shift()!); 121 | 122 | data.periods.push({ 123 | from: { hour: fromHour, minute: fromMinute }, 124 | to: { hour: toHour, minute: toMinute }, 125 | state 126 | }); 127 | }) 128 | return data; 129 | } 130 | 131 | private getReloadIcon(): Template { 132 | if (!this.config.reload_action) 133 | return nothing; 134 | return html`
`; 135 | } 136 | 137 | private onReload() { 138 | if (this.config.reload_action) 139 | this.callService(this.config.reload_action); 140 | } 141 | 142 | getGraphColors(): ScheduleGraphColors { 143 | const darkMode = this.hass.themes.darkMode; 144 | return { 145 | background: darkMode ? '#1d1d1d' : '#fff', 146 | text: darkMode ? '#eee' : '#000', 147 | green: '#34D058', 148 | green_past: darkMode ? '#214129' : '#d2f2da', 149 | red: '#F04F5A', 150 | red_past: darkMode ? '#472729' : '#f8d8da', 151 | yellow: '#FFCA2C', 152 | yellow_past: darkMode ? '#4a3f20' : '#fbf0d1', 153 | }; 154 | } 155 | 156 | getRelativeDate(date: string) { 157 | const dateParts = date.split('.'); 158 | const inputDate = new Date(`${dateParts[2]}.${dateParts[1]}.${dateParts[0]}`); 159 | const currentDate = new Date(); 160 | 161 | const inputDateOnly = new Date(inputDate.getFullYear(), inputDate.getMonth(), inputDate.getDate()); 162 | const currentDateOnly = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate()); 163 | 164 | const differenceInTime = inputDateOnly.getTime() - currentDateOnly.getTime(); 165 | const differenceInDays = differenceInTime / (1000 * 3600 * 24); 166 | 167 | if (differenceInDays === 0) { 168 | return "Today"; 169 | } else if (differenceInDays === 1) { 170 | return "Tomorrow"; 171 | } else if (differenceInDays === -1) { 172 | return "Yesterday"; 173 | } else { 174 | return date; 175 | } 176 | } 177 | 178 | private callService(action: PowerOutageCardAction) { 179 | const { service, service_data, target } = action; 180 | const [domain, name] = service.split('.'); 181 | 182 | this.hass.callService(domain, name, { 183 | ...service_data, 184 | entity_id: target, 185 | }); 186 | } 187 | 188 | private state(id: string): string { 189 | return this.hass.states[id].state; 190 | } 191 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Power outage schedule card 2 | 3 | Power outage schedule card for Home Assistant. The card is designed to work with the information provided by svitlo.oe.if.ua (see data retrieval example). It shows the schedule for today and tomorrow (if provided). 4 | 5 | ![Power outage schedule card example](/images/POS.gif) 6 | 7 | ## Installation 8 | 9 | ### HACS 10 | 11 | This card can be installed using [HACS](https://hacs.xyz/) (Home Assistant Community Store) custom repositories. 12 | 13 | 1. Install HACS. 14 | 1. Go to HACS -> Frontend -> Custom repositories (in the menu). 15 | 1. Set the repository to `https://github.com/strange-v/power-outage-schedule-card` and the category to `Lovelace`. 16 | 1. Click Explore & download repositories and search for `power outage schedule card`. 17 | 1. Select the card and click download. 18 | 1. Accept reload. 19 | 20 | ### Manual 21 | 22 | 1. Download `power-outage-schedule-card.js` file from the [latest-release](https://github.com/strange-v/power-outage-schedule-card/releases/latest) or `dist` folder. 23 | 1. Put `power-outage-schedule-card.js` file into your `config/www` folder (this can be done through the "File editor" addon if HA OS is used). 24 | 1. Add a reference to `power-outage-schedule-card.js` in Lovelace resources. 25 | 1. Go to dashboard. 26 | 2. Click edit and open the three dots menu. 27 | 3. Click Manage resources. 28 | 4. Click Add Resource. 29 | 5. Set the URL to `/local/power-outage-schedule-card.js` and the Resource type to `JavaScript module` 30 | 31 | ## Configuration 32 | 33 | 1. Go to `Settings` - `Devices & Services` - `Helpers` and create a new `Text` helper named "OE Queue", set the value to "2.2" (will be updated later) 34 | 1. Add REST sensors for today and tomorrow 35 | 36 | ```yaml 37 | rest: 38 | - resource: "https://be-svitlo.oe.if.ua/schedule-by-queue" 39 | scan_interval: 1800 40 | headers: 41 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0" 42 | "Accept": "*/*" 43 | "Accept-Language": "en-US,en;q=0.5" 44 | "Accept-Encoding": "gzip, deflate, br, zstd" 45 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" 46 | "Origin": "https://svitlo.oe.if.ua" 47 | "Connection": "keep-alive" 48 | "Referer": "https://svitlo.oe.if.ua/" 49 | params: 50 | "queue": > 51 | {{ states('input_text.oe_queue') }} 52 | method: GET 53 | sensor: 54 | - name: "OE Today" 55 | unique_id: 5fd8c49686d04772be7d51c2ccdba1f5 56 | value_template: >- 57 | {% set today = now().strftime('%d.%m.%Y') %} 58 | {% set data = value_json | selectattr("eventDate", "equalto", today) | list %} 59 | {% if data | length > 0 %} 60 | {% set data = data | last %} 61 | {% set queue = data.queues[states('input_text.oe_queue')] %} 62 | {{ data.eventDate }};{{ data.scheduleApprovedSince }}; 63 | {%- for period in queue %}{{ period.from }}-{{ period.to }}-{{ period.status }};{%- endfor %} 64 | {% else %} 65 | unknown 66 | {% endif %} 67 | - name: "OE Tomorrow" 68 | unique_id: c8450a42626a4769a5f612d16f3dfc70 69 | value_template: >- 70 | {% set tomorrow = (now() + timedelta(days=1)).strftime('%d.%m.%Y') %} 71 | {% set data = value_json | selectattr("eventDate", "equalto", tomorrow) | list %} 72 | {% if data | length > 0 %} 73 | {% set data = data | last %} 74 | {% set queue = data.queues[states('input_text.oe_queue')] %} 75 | {{ data.eventDate }};{{ data.scheduleApprovedSince }}; 76 | {%- for period in queue %}{{ period.from }}-{{ period.to }}-{{ period.status }};{%- endfor %} 77 | {% else %} 78 | unknown 79 | {% endif %} 80 | ``` 81 | 82 | 1. Configure automatic update of the OE Queue 83 | 1. Add a new rest sensor 84 | 85 | ```yaml 86 | - resource: "https://be-svitlo.oe.if.ua/GavGroupByAccountNumber" 87 | scan_interval: 3600 88 | headers: 89 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0" 90 | "Accept": "*/*" 91 | "Accept-Language": "en-US,en;q=0.5" 92 | "Accept-Encoding": "gzip, deflate, br, zstd" 93 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" 94 | "Origin": "https://svitlo.oe.if.ua" 95 | "Connection": "keep-alive" 96 | "Referer": "https://svitlo.oe.if.ua/" 97 | params: 98 | "accountNumber": "XXXXXXXX" 99 | "userSearchChoice": "pob" 100 | "address": "" 101 | method: POST 102 | sensor: 103 | - name: "OE Queue" 104 | unique_id: 3ac7ab5e3ce64c558f86dd6c9c600677 105 | value_template: >- 106 | {% set data = value_json['current'] %} 107 | {% if data.hasQueue == "yes" %} 108 | {{ data.queue }}.{{ data.subqueue }} 109 | {% else %} 110 | unknown 111 | {% endif %} 112 | ``` 113 | 114 | You can use your address or personal account number to fetch the actual data about your queue. 115 | Put your oblenergo personal account number into the `accountNumber` parameter or your address (e.g., `Івано-Франківськ,Індустріальна,32`) into the `address` parameter. 116 | 1. Add automation that updates the previously created `Text` helper and related sensors 117 | 118 | ```yaml 119 | alias: Update OE Queue 120 | description: "" 121 | triggers: 122 | - trigger: state 123 | entity_id: 124 | - sensor.queue 125 | to: null 126 | id: queue 127 | conditions: 128 | - condition: not 129 | conditions: 130 | - condition: state 131 | entity_id: sensor.queue 132 | state: unavailable 133 | - condition: state 134 | entity_id: sensor.queue 135 | state: unknown 136 | actions: 137 | - action: input_text.set_value 138 | metadata: {} 139 | data: 140 | value: "{{ states('sensor.queue') }}" 141 | target: 142 | entity_id: input_text.oe_queue 143 | - action: homeassistant.update_entity 144 | data: 145 | entity_id: 146 | - sensor.oe_today 147 | mode: single 148 | ``` 149 | 150 | 1. Configure the card using YAML (the UI editor is unsupported) 151 | 152 | ```yaml 153 | type: custom:power-outage-schedule-card 154 | queue_entity: input_text.oe_queue 155 | today_entity: sensor.oe_today 156 | tomorrow_entity: sensor.oe_tomorrow 157 | hide_past_hours: true 158 | title: Power outage schedule 159 | empty_text: The schedule for hourly outages will be published by the end of the day. 160 | reload_action: 161 | service: homeassistant.update_entity 162 | target: sensor.oe_queue 163 | ``` 164 | 165 | ## Notifications 166 | 167 | ![Notification: power outage schedule added](/images/notification_schedule_added.png) ![Notification: power outage schedule changed](/images/notification_schedule_changed.png) 168 | 169 | [Read more](/notifications/) about how to configure notifications. 170 | -------------------------------------------------------------------------------- /notifications/scripts.yaml: -------------------------------------------------------------------------------- 1 | script: 2 | notify_power_outage_schedule_changed: 3 | alias: Notify power outage schedule changed 4 | sequence: 5 | - if: 6 | - condition: template 7 | value_template: >- 8 | {% set is_valid = current != 'unknown' and current != 'unavailable' and previous != 'unavailable' %} 9 | {% set current_date = current.split(';')[0] %} 10 | {% set previous_date = previous.split(';')[0] %} 11 | {% set current_data = ';'.join(current.split(';')[2:]) %} 12 | {% set previous_data = ';'.join(previous.split(';')[2:]) %} 13 | {{ is_valid and current_date == previous_date and current_data != previous_data }} 14 | then: 15 | - service: notify.notifyfamily 16 | data: 17 | title: Power outage schedule changed 18 | data: 19 | clickAction: /dashboard-mobile/0#power-pop-up 20 | message: >- 21 | {% set current_data = current.split(';')[2:] %} 22 | {% set previous_data = previous.split(';')[2:] %} 23 | {% set ns = namespace(current_green=0, current_red=0, current_yellow=0, previous_green=0, previous_red=0, previous_yellow=0) %} 24 | 25 | {% for period in current_data %} 26 | {% if period %} 27 | {% set parts = period.split('-') %} 28 | {% set state = parts[2] %} 29 | {% set start_s = parts[0].split(':') %} 30 | {% set end_s = parts[1].split(':') %} 31 | {% set start_h = (start_s[0] | int) + (start_s[1] | int) / 60 %} 32 | {% set end_h = (end_s[0] | int) + (end_s[1] | int) / 60 %} 33 | {% if end_h <= start_h %} 34 | {% set duration = (24 - start_h) + end_h %} 35 | {% else %} 36 | {% set duration = end_h - start_h %} 37 | {% endif %} 38 | 39 | {% if state == '1' %} 40 | {% set ns.current_red = ns.current_red + duration %} 41 | {% elif state == '2' %} 42 | {% set ns.current_yellow = ns.current_yellow + duration %} 43 | {% endif %} 44 | {% endif %} 45 | {% endfor %} 46 | {% set ns.current_green = 24 - ns.current_red - ns.current_yellow %} 47 | 48 | {% for period in previous_data %} 49 | {% if period %} 50 | {% set parts = period.split('-') %} 51 | {% set state = parts[2] %} 52 | {% set start_s = parts[0].split(':') %} 53 | {% set end_s = parts[1].split(':') %} 54 | {% set start_h = (start_s[0] | int) + (start_s[1] | int) / 60 %} 55 | {% set end_h = (end_s[0] | int) + (end_s[1] | int) / 60 %} 56 | {% if end_h <= start_h %} 57 | {% set duration = (24 - start_h) + end_h %} 58 | {% else %} 59 | {% set duration = end_h - start_h %} 60 | {% endif %} 61 | 62 | {% if state == '1' %} 63 | {% set ns.previous_red = ns.previous_red + duration %} 64 | {% elif state == '2' %} 65 | {% set ns.previous_yellow = ns.previous_yellow + duration %} 66 | {% endif %} 67 | {% endif %} 68 | {% endfor %} 69 | {% set ns.previous_green = 24 - ns.previous_red - ns.previous_yellow %} 70 | 71 | {% if ns.current_green != ns.previous_green %} 72 | {% set diff = ns.current_green - ns.previous_green %} 73 | {% if diff > 0 %} Plus {% else %} Minus {% endif %} {{ '{:.1f}'.format(diff|float|abs).rstrip('0').rstrip('.') }} {% if diff | abs == 1 %}hour{% else %}hours{% endif %} of electricity for {{ day }}. 74 | {%- endif %} 75 | {%- if ns.current_red != ns.previous_red %} 76 | {%- set diff = ns.current_red - ns.previous_red %} 77 | {% if diff > 0 %} + {% else %} - {% endif %}{{ '{:.1f}'.format(diff|float|abs).rstrip('0').rstrip('.') }} {% if diff | abs == 1 %}hour{% else %}hours{% endif %} in red zone. 78 | {%- endif %} 79 | {%- if ns.current_yellow != ns.previous_yellow %} 80 | {%- set diff = ns.current_yellow - ns.previous_yellow %} 81 | {% if diff > 0 %} + {% else %} - {% endif %}{{ '{:.1f}'.format(diff|float|abs).rstrip('0').rstrip('.') }} {% if diff | abs == 1 %}hour{% else %}hours{% endif %} in yellow zone. 82 | {% endif %} 83 | fields: 84 | previous: 85 | selector: 86 | text: 87 | name: previous 88 | required: true 89 | current: 90 | selector: 91 | text: 92 | name: current 93 | required: true 94 | day: 95 | selector: 96 | text: 97 | name: day 98 | required: true 99 | description: '' 100 | 101 | notify_power_outage_schedule_added: 102 | alias: Notify power outage schedule added 103 | sequence: 104 | - if: 105 | - condition: template 106 | value_template: >- 107 | {% set is_valid = current != 'unknown' and current != 'unavailable' %} 108 | {% set is_different = current != previous %} 109 | {{ is_valid and is_different }} 110 | then: 111 | - data: 112 | title: Power outage schedule for {{ day }} 113 | data: 114 | clickAction: /dashboard-mobile/0#power-pop-up 115 | message: >- 116 | {% set current_data = current.split(';')[2:] %} 117 | {% set ns = namespace(current_green=0.0, current_red=0.0, current_yellow=0.0) %} 118 | 119 | {% for period in current_data %} 120 | {% if period %} 121 | {% set parts = period.split('-') %} 122 | {% set state = parts[2] %} 123 | {% set start_s = parts[0].split(':') %} 124 | {% set end_s = parts[1].split(':') %} 125 | {% set start_h = (start_s[0] | int) + (start_s[1] | int) / 60 %} 126 | {% set end_h = (end_s[0] | int) + (end_s[1] | int) / 60 %} 127 | {% if end_h <= start_h %} 128 | {% set duration = (24 - start_h) + end_h %} 129 | {% else %} 130 | {% set duration = end_h - start_h %} 131 | {% endif %} 132 | 133 | {% if state == '1' %} 134 | {% set ns.current_red = ns.current_red + duration %} 135 | {% elif state == '2' %} 136 | {% set ns.current_yellow = ns.current_yellow + duration %} 137 | {% endif %} 138 | {% endif %} 139 | {% endfor %} 140 | {% set ns.current_green = 24 - ns.current_red - ns.current_yellow %} 141 | 142 | You will have {{ '{:.1f}'.format(ns.current_green|float).rstrip('0').rstrip('.') }} {% if ns.current_green == 1 %}hour{% else %}hours{% endif %} of electricity. 143 | 144 | {% if ns.current_red > 0 %} {{ '{:.1f}'.format(ns.current_red|float).rstrip('0').rstrip('.') }} {% if ns.current_red == 1 %}hour{% else %}hours{% endif %} in red zone. {% endif %} 145 | 146 | {% if ns.current_yellow > 0 %} {{ '{:.1f}'.format(ns.current_yellow|float).rstrip('0').rstrip('.') }} {% if ns.current_yellow == 1 %}hour{% else %}hours{% endif %} in yellow zone. {% endif %} 147 | action: notify.notifyfamily 148 | fields: 149 | previous: 150 | selector: 151 | text: null 152 | name: previous 153 | required: true 154 | current: 155 | selector: 156 | text: null 157 | name: current 158 | required: true 159 | day: 160 | selector: 161 | text: null 162 | name: day 163 | required: true 164 | description: "" 165 | -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing } from 'lit'; 2 | import { 3 | Template, 4 | ScheduleGraphColors, 5 | PowerOutagePeriod, 6 | Time, 7 | } from './types' 8 | 9 | export function getScheduleGraph(queue: string, day: string, periods: PowerOutagePeriod[], today: boolean, colors: ScheduleGraphColors): Template { 10 | const cls: Record = {}; 11 | const now = new Date; 12 | let hour = 0; 13 | let idx = 0; 14 | while (hour < 24) { 15 | cls[idx] = ''; 16 | 17 | if (today && (now.getHours() > hour || (now.getHours() == hour && now.getMinutes() > 30 && !(idx % 2)))) { 18 | cls[idx] += 'past'; 19 | } 20 | 21 | const timeToNumber = (time: Time) => time.hour + time.minute / 60; 22 | const currentTime: Time = { hour: Math.floor(hour), minute: (hour % 1) * 60 }; 23 | const current = timeToNumber(currentTime); 24 | 25 | for (const period of periods) { 26 | let from = timeToNumber(period.from); 27 | let to = timeToNumber(period.to); 28 | 29 | if (to === 0 && from > 0) { 30 | to = 24; 31 | } 32 | 33 | if (current >= from && current < to) { 34 | cls[idx] += period.state === 1 ? ' red' : ' yellow'; 35 | } 36 | } 37 | 38 | hour += 0.5; 39 | idx += 1; 40 | } 41 | 42 | return html` 43 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 0 181 | 1 182 | 2 183 | 3 184 | 4 185 | 5 186 | 6 187 | 7 188 | 8 189 | 9 190 | 10 191 | 11 192 | 12 193 | 13 194 | 14 195 | 15 196 | 16 197 | 17 198 | 18 199 | 19 200 | 20 201 | 21 202 | 22 203 | 23 204 | 205 | ${queue} 206 | ${day} 207 | 208 | 209 | `; 210 | } --------------------------------------------------------------------------------