├── info.md ├── .gitignore ├── hvv-card.png ├── hvv-card-light.png ├── hacs.json ├── .github └── workflows │ └── test.yml ├── package.json ├── LICENSE ├── tests └── hvv-card.test.js ├── README.md └── hvv-card.js /info.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.vscode/ 2 | -------------------------------------------------------------------------------- /hvv-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilstgmd/hvv-card/HEAD/hvv-card.png -------------------------------------------------------------------------------- /hvv-card-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilstgmd/hvv-card/HEAD/hvv-card-light.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HVV Departure Card", 3 | "content_in_root": false, 4 | "filename": "hvv-card.js", 5 | "country": ["DE"], 6 | "render_readme": true 7 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | - run: npm install 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hvv-card", 3 | "version": "0.0.1", 4 | "description": "Test setup for hvv-card", 5 | "scripts": { 6 | "test": "jest" 7 | }, 8 | "jest": { 9 | "testEnvironment": "jsdom" 10 | }, 11 | "devDependencies": { 12 | "jest": "^29.7.0", 13 | "jest-environment-jsdom": "^29.7.0", 14 | "jsdom": "^23.0.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nils Meder 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 | -------------------------------------------------------------------------------- /tests/hvv-card.test.js: -------------------------------------------------------------------------------- 1 | // jest setup using jsdom environment 2 | 3 | describe('hvv-card custom element', () => { 4 | beforeAll(() => { 5 | // stub customElements and base element used by hvv-card 6 | class MockLitElement extends HTMLElement {} 7 | // minimal html/css template functions 8 | MockLitElement.prototype.html = (strings, ...vals) => { 9 | let out = ''; 10 | for (let i = 0; i < strings.length; i++) { 11 | out += strings[i]; 12 | if (i < vals.length) { 13 | let v = vals[i]; 14 | if (Array.isArray(v)) v = v.join(''); 15 | out += v; 16 | } 17 | } 18 | return out; 19 | }; 20 | MockLitElement.prototype.css = MockLitElement.prototype.html; 21 | 22 | class HaPanelLovelace extends MockLitElement {} 23 | customElements.define('ha-panel-lovelace', HaPanelLovelace); 24 | }); 25 | 26 | test('defines hvv-card element', () => { 27 | require('../hvv-card.js'); 28 | expect(customElements.get('hvv-card')).toBeDefined(); 29 | }); 30 | 31 | test('render shows unavailable warning icon', () => { 32 | require('../hvv-card.js'); 33 | const card = document.createElement('hvv-card'); 34 | card.setConfig({ entities: ['sensor.unavail'] }); 35 | card.hass = { 36 | states: { 37 | 'sensor.unavail': { 38 | state: 'unavailable', 39 | attributes: { friendly_name: 'Test Entity' } 40 | } 41 | } 42 | }; 43 | 44 | const output = card.render(); 45 | expect(String(output)).toContain('mdi:vector-polyline-remove'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HVV Card 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 4 | [![release badge](https://img.shields.io/github/v/release/nilstgmd/hvv-card.svg?style=for-the-badge)](https://github.com/nilstgmd/hvv-card/releases) 5 | 6 | HVV departures card for Home Assistant. 7 | 8 | This custom UI card shows the next departures at a certain station based on the [HVV Departures integration](https://www.home-assistant.io/integrations/hvv_departures). 9 | 10 | ![HVV Card dark](https://github.com/nilstgmd/hvv-card/blob/main/hvv-card.png) 11 | ![HVV Card light](https://github.com/nilstgmd/hvv-card/blob/main/hvv-card-light.png) 12 | 13 | 14 | ## Installation 15 | 16 | ### Prerequisite 17 | 18 | Install the [HVV Departures integration](https://www.home-assistant.io/integrations/hvv_departures) and setup a departure sensor, e.g. `sensor.departures_at_jungfernstieg`. 19 | 20 | ### HACS 21 | 22 | HVV Card is available as a custom HACS repository. This is the recommended way to install the custom cards. 23 | 24 | 1. Open HACS and add this repository to your "Custom repositories". 25 | 1. "HVV Departure Card" will show up in the "Frontend" section 26 | 1. Click "Install" and continue to configure a card 27 | 28 | ### Manual 29 | 30 | 1. Download the [hvv-card.js](https://raw.githubusercontent.com/nilstgmd/hvv-card/main/hvv-card.js) to `/config/www/`. 31 | 1. Add the following to resources in your Lovelace config or use the [Lovelace configuration UI](https://developers.home-assistant.io/docs/frontend/custom-ui/registering-resources/).: 32 | 33 | ```yaml 34 | resources: 35 | - url: /local/hvv-card.js 36 | type: module 37 | ``` 38 | 39 | ### Configuration 40 | 41 | Add a card with type `custom:hvv-card`: 42 | 43 | ```yaml 44 | type: 'custom:hvv-card' 45 | entities: 46 | - sensor.departures_at_jungfernstieg 47 | - sensor.departures_at_schlump 48 | ``` 49 | 50 | #### Options 51 | 52 | | Name | Type | Default | Since | Description | 53 | |------|------|---------|-------|-------------| 54 | | type | string | **required** | v0.1.0 | `custom:hvv-card` | 55 | | entities | array | **required** | v0.2.0 | Array of entity_ids from the HVV departures integration, that will be shown on the card. | 56 | | title | string | optional (Default: HVV Departures) | v0.2.0 | Title shown on the card. | 57 | | show_title | boolean | optional (Default: true) | v0.2.0 | Shows the title of the card | 58 | | max | int | optional (Default: 5) | v0.1.0 | Set the max. listed departures | 59 | | show_time | boolean | optional (Default: false) | v0.1.7 | Shows the departure time instead of the minutes | 60 | | show_name | boolean | optional (Default: true) | v0.2.0 | Shows the name of the departure sensor | 61 | 62 | #### Example 63 | 64 | Here is a more exhaustive example of a configuration: 65 | 66 | ```yaml 67 | type: custom:hvv-card 68 | entities: 69 | - sensor.departures_at_jungfernstieg 70 | - sensor.departures_at_schlump 71 | max: 10 72 | show_time: false 73 | show_title: true 74 | show_name: false 75 | title: HVV 76 | ``` 77 | 78 | ## Development 79 | 80 | Install dependencies and run the tests with: 81 | 82 | ```bash 83 | npm install 84 | ======= 85 | Run the tests with: 86 | npm test 87 | ``` 88 | -------------------------------------------------------------------------------- /hvv-card.js: -------------------------------------------------------------------------------- 1 | const LitElement = Object.getPrototypeOf( 2 | customElements.get("ha-panel-lovelace") 3 | ); 4 | const html = LitElement.prototype.html; 5 | const css = LitElement.prototype.css; 6 | 7 | function hasConfigOrEntityChanged(element, changedProps) { 8 | if (changedProps.has("_config")) { 9 | return true; 10 | } 11 | 12 | const oldHass = changedProps.get("hass"); 13 | if (!oldHass) { 14 | return true; 15 | } 16 | 17 | for (const entity of element._config.entities) { 18 | if (oldHass.states[entity] !== element.hass.states[entity]) { 19 | return true; 20 | } 21 | } 22 | 23 | return false; 24 | } 25 | 26 | class HvvCard extends LitElement { 27 | static get properties() { 28 | return { 29 | _config: {}, 30 | hass: {} 31 | }; 32 | } 33 | 34 | setConfig(config) { 35 | if (config.entity) { 36 | throw new Error("The entity property is deprecated, please use entities instead.") 37 | } 38 | 39 | if (!config.entities) { 40 | throw new Error("The entities property is required.") 41 | } 42 | this._config = config; 43 | } 44 | 45 | shouldUpdate(changedProps) { 46 | return hasConfigOrEntityChanged(this, changedProps); 47 | } 48 | 49 | render() { 50 | if (!this._config || !this.hass) { 51 | return html ``; 52 | } 53 | 54 | var title = this._config.title ? this._config.title : "HVV Departures"; 55 | var showTitle = this._config.show_title !== false; 56 | var showName = this._config.show_name !== false; 57 | 58 | return html ` 59 | 60 | ${showTitle ? 61 | html` 62 |

${title}

63 | ` 64 | : "" 65 | } 66 | 67 | ${this._config.entities.map((ent) => { 68 | const stateObj = this.hass.states[ent]; 69 | if (!stateObj) { 70 | return html ` 71 | 78 | 79 |
80 | Entity not available: ${ent} 81 |
82 |
83 | `; 84 | } 85 | 86 | if (stateObj.state == 'unavailable') { 87 | return html` 88 |
89 | ${showName && stateObj.attributes['friendly_name'] 90 | ? html` 91 |

${stateObj.attributes['friendly_name']}

92 | ` 93 | : ""} 94 |
95 | `; 96 | } 97 | 98 | const today = new Date(); 99 | const max = this._config.max ? this._config.max : 5; 100 | var count = 0; 101 | 102 | return html ` 103 |
104 | ${showName && stateObj.attributes['friendly_name'] 105 | ? html` 106 |

${stateObj.attributes['friendly_name']}

107 | ` 108 | : "" 109 | } 110 | 111 | ${stateObj.attributes['next'].map(attr => { 112 | const direction = attr['direction']; 113 | const line = attr['line']; 114 | const type = attr['type']; 115 | const delay_seconds = attr['delay']; 116 | const delay_minutes = (delay_seconds / 60); 117 | const departure = new Date(attr["departure"]); 118 | const diffMs = departure - today; 119 | const departureHours = Math.floor((diffMs / (1000*60*60)) % 24); 120 | const departureMins = Math.round((diffMs / (1000*60)) % 60); 121 | 122 | count++; 123 | 124 | return count <= max 125 | ? html` 126 | 127 | 128 | 129 | 146 | 147 | ` 148 | : html ``; 149 | })} 150 |
${line}${direction} 130 | ${this._config.show_time ? 131 | departure.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : 132 | departureHours > 0 ? 133 | departureHours + `:` + departureMins : 134 | departureMins 135 | } 136 | ${delay_minutes > 0 ? 137 | html`+${delay_minutes}` : 138 | ``} 139 | ${delay_minutes <= 0 && this._config.show_time ? 140 | `` : 141 | departureHours > 0 ? 142 | `h:min` : 143 | `min` 144 | } 145 |
151 |
152 | `; 153 | })} 154 |
155 | `; 156 | } 157 | 158 | getCardSize() { 159 | return 1; 160 | } 161 | 162 | static get styles() { 163 | return css ` 164 | table { 165 | width: 100%; 166 | padding: 6px 14px; 167 | } 168 | 169 | td { 170 | padding: 3px 0px; 171 | } 172 | 173 | td.narrow { 174 | white-space: nowrap; 175 | } 176 | 177 | td.expand { 178 | width: 95%; 179 | } 180 | 181 | span.line { 182 | font-weight: bold; 183 | font-size: 0.9em; 184 | padding: 3px 8px 2px 8px; 185 | color: #ffffff; 186 | background-color: #888888; 187 | margin-right: 0.7em; 188 | } 189 | 190 | span.delay_minutes { 191 | color: #e2001a; 192 | } 193 | 194 | span.S, span.A{ 195 | background-color: #009252; 196 | border-radius: 999px; 197 | } 198 | 199 | span.U { 200 | border-radius: 0px; 201 | } 202 | 203 | span.Bus, span.XpressBus, span.Schnellbus, span.NachtBus { 204 | background-color: #e2001a; 205 | clip-path: polygon(20% 0, 80% 0, 100% 50%, 80% 100%, 20% 100%, 0 50%); 206 | width: 48px; 207 | margin-left: 0; 208 | } 209 | 210 | span.XpressBus { 211 | background-color: #1a962b; 212 | } 213 | 214 | span.NachtBus { 215 | background-color: #000000; 216 | } 217 | 218 | span.Schiff { 219 | background-color: #009dd1; 220 | clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%); 221 | } 222 | 223 | span.ICE, span.RE, span.EC, span.IC, span.RB, span.R { 224 | background-color: transparent; 225 | color: #000; 226 | } 227 | 228 | span.U1 { 229 | background-color: #1c6ab3; 230 | } 231 | 232 | span.U2 { 233 | background-color: #e2021b; 234 | } 235 | 236 | span.U3 { 237 | background-color: #fddd00; 238 | } 239 | 240 | span.U4 { 241 | background-color: #0098a1; 242 | } 243 | 244 | span.S1 { 245 | background-color: #31962b; 246 | } 247 | 248 | span.S2 { 249 | background-color: #b51143; 250 | } 251 | 252 | span.S3 { 253 | background-color: #622181; 254 | } 255 | 256 | span.S4 { 257 | background-color: #BF0880; 258 | } 259 | 260 | span.S5 { 261 | background-color: #008ABE; 262 | } 263 | 264 | span.S11 { 265 | background-color: #31962b; 266 | } 267 | 268 | span.S21 { 269 | background-color: #b51143; 270 | } 271 | 272 | span.S31 { 273 | background-color: #622181; 274 | } 275 | `; 276 | } 277 | } 278 | customElements.define("hvv-card", HvvCard); 279 | --------------------------------------------------------------------------------