├── .gitignore ├── README.md ├── custom_components └── isc │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── manifest.json │ ├── sensor.py │ └── translations │ ├── en.json │ └── nb.json ├── hacs.json ├── img_1_reg.png ├── img_complex_cal.png ├── img_example.png ├── img_feiertage.png └── img_fussi.png /.gitignore: -------------------------------------------------------------------------------- 1 | testing/* 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ICS 2 | 3 | Adds a sensor to Home Assistant that displays the date and number of days to the next event. 4 | E.g. 5 days until the trash will be picked up. The information will be read from a user definded 5 | ics file. 6 | 7 | **This component will set up the following platforms.** 8 | 9 | Platform | Description 10 | -- | -- 11 | `sensor` | Show date and remaining days to event 12 | 13 | ![Example](img_example.png) 14 | 15 | 16 | ## Features 17 | 18 | - Supports ICS file (local and online) with reoccuring events 19 | - Events can be filtered, so you can tell it to look only for certain events 20 | - Has attributes that calculated the number of days, so you can easily run a automation trigger, show start / end of the events 21 | - Low CPU and network usage, as it only updates once per day (whenever the date changes) or when the event is over (assuming force reload is disabled) 22 | - If multiple events occure on the same time, all title will be shown, connected with "/" 23 | - Can be configured to look for 'the event after the event' 24 | 25 | # Installation 26 | 27 | ## HACS 28 | 29 | The easiest way to add this to your Homeassistant installation is using [HACS]. 30 | 31 | It's recommended to restart Homeassistent directly after the installation without any change to the Configuration. 32 | Homeassistent will install the dependencies during the next reboot. After that you can add and check the configuration without error messages. 33 | This is nothing special to this Integration but the same for all custom components. 34 | 35 | 36 | ## Manual 37 | 38 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 39 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 40 | 3. In the `custom_components` directory (folder) create a new folder called `ics`. 41 | 4. Download _all_ the files from the `custom_components/ics/` directory (folder) in this repository. 42 | 5. Place the files you downloaded in the new directory (folder) you created. 43 | 6. Follow the instructions under [Configuration](#Configuration) below. 44 | 45 | Using your HA configuration directory (folder) as a starting point you should now also have this: 46 | 47 | ```text 48 | custom_components/ics/.translations/en.json 49 | custom_components/ics/__init__.py 50 | custom_components/ics/manifest.json 51 | custom_components/ics/sensor.py 52 | custom_components/ics/config_flow.py 53 | custom_components/ics/const.py 54 | 55 | ``` 56 | 57 | # Setup 58 | 59 | All you need to have is a link to a ICS file, e.g. https://www.rmg-gmbh.de/download/Hamb%C3%BChren.ics 60 | 61 | ## Configuration options 62 | 63 | Key | Type | Required | Default | Description 64 | -- | -- | -- | -- | -- 65 | `name` | `string` | `true` | `None` | The friendly name of the sensor 66 | `url` | `string` | `true` | `None` | The url to the ics file usually some weblink, but can also be local file e.g. https://www.rmg-gmbh.de/download/Hamb\%C3%BChren.ics or file:///tmp/test.ics 67 | `id` | `int` | `true` | `None` | A number to identify your sensor later on. e.g. for id=1 the entity will be sensor.ics_1 using id 1 a second will result in sensor.ics_1_2 68 | `timeformat` | `string` | `false` | `"%A, %d.%m.%Y"` | The format that is used to display the date see http://strftime.org/ for more infomation 69 | `lookahead` | `int` | `false` | `365` | The number of days that limits the forecast. E.g. 1 will only show the events of today 70 | `startswith` | `string` | `false` | `""` | A filter that will limit the display of events. E.g. if your file contains multiple entries and you only want to know one type at per sensor, simply create multiple sensors and filter. Have a look at sensor 3 and 4 above. startswith: Bio will ohne show events that start with Bio. 71 | `contains` | `string` | `false` | `""` | A filter like startswith, but this will search within the string instead of the start. 72 | `show_blank` | `string` | `false` | `""` | Indicates whether to show empty events (events without title), and what should be used as title instead. e.g. "Meeting123" would show events with empty title with the string "Meeting123". An empty string (default) or " " will avoid showing blank events. 73 | `force_update` | `int` | `false` | `0` | Force to update the data with given intervall (seconds). This can be useful if the calendar is very dynamic, but pointless for almost static calendars. The calendar will reload at midnight and once the (start/end) of the event is over regardless of this setting. 0 = Disabled 74 | `show_remaining` | `bool` | `false` | `true` | Show the remaining days in the sensor state, close to the date. 75 | `show_ongoing` | `bool` | `false` | `false` | Show events that have already started but not finished. 76 | `group_events` | `bool` | `false` | `true` | Show events with same start date as one event 77 | `n_skip` | `int` | `false` | `0` | Skip the given amount of events, useful to show the appointment AFTER the next appointment 78 | `description_in_state` | `bool` | `false` | `false` | Show the title of the events in the state 79 | `icon` | `string` | `false` | `mdi:calendar` | MDI Icon string, check https://materialdesignicons.com/ 80 | 81 | ## GUI configuration 82 | 83 | As of 2020/04/20 config flow is supported and is the prefered way to setup the integration. (No need to restart Home-Assistant) 84 | 85 | ## Manual configuration 86 | 87 | To enable the sensor, add the following lines to your `configuration.yaml` file and replace the link accordingly: 88 | 89 | ```yaml 90 | # Example entry for configuration.yaml 91 | sensor: 92 | 93 | - platform: ics 94 | name: Packaging 95 | url: https://www.rmg-gmbh.de/download/Hamb%C3%BChren.ics 96 | id: 1 97 | icon: "mdi:recycle" 98 | 99 | - platform: ics 100 | name: Trash 101 | url: http://www.zacelle.de/privatkunden/muellabfuhr/abfuhrtermine/?tx_ckcellextermine_pi1%5Bot%5D=148&tx_ckcellextermine_pi1%5Bics%5D=0&tx_ckcellextermine_pi1%5Bstartingpoint%5D=234&type=3333 102 | id: 2 103 | icon: "mdi:trash-can-outline" 104 | 105 | - platform: ics 106 | name: Trash 2 107 | url: https://www.ab-peine.de/mcalendar/export_termine.php?menuid=185&area=141&year=2020 108 | startswith: Rest 109 | id: 3 110 | icon: "mdi:trash-can-outline" 111 | 112 | - platform: ics 113 | name: Bio 114 | url: https://www.ab-peine.de/mcalendar/export_termine.php?menuid=185&area=141&year=2020 115 | startswith: Bio 116 | show_ongoing: true 117 | id: 4 118 | 119 | - platform: ics 120 | name: Work 121 | url: https://calendar.google.com/calendar/ical/xxxxxxxxxxxxxgroup.calendar.google.com/private-xxxxxxxxxxxxxxx/basic.ics 122 | timeformat: "%d.%m. %H:%M" 123 | force_update: 600 124 | show_remaining: false 125 | id: 5 126 | 127 | - platform: ics 128 | name: Work today 129 | url: https://calendar.google.com/calendar/ical/xxxxxxxxxxxxxgroup.calendar.google.com/private-xxxxxxxxxxxxxxx/basic.ics 130 | timeformat: "%H:%M" 131 | lookahead: 1 132 | force_update: 600 133 | show_remaining: false 134 | id: 6 135 | 136 | - platform: template 137 | sensors: 138 | ics_5_txt: 139 | value_template: '{{ states.sensor.ics_5.attributes.description}} @ {{states.sensor.ics_5.state}}' 140 | friendly_name: "Work Next" 141 | ics_6_txt: 142 | value_template: '{{ states.sensor.ics_6.attributes.description}} @ {{states.sensor.ics_6.state}}' 143 | friendly_name: "Work today" 144 | 145 | 146 | ``` 147 | 148 | # Automation 149 | 150 | Example that executes on the day before one of the 'events' 151 | 152 | ```yaml 153 | automation: 154 | - alias: 'trash pickup msg' 155 | initial_state: 'on' 156 | trigger: 157 | - platform: time_pattern 158 | hours: 19 159 | minutes: 00 160 | seconds: 00 161 | condition: 162 | condition: or 163 | conditions: 164 | - condition: template 165 | value_template: "{{ state_attr('sensor.ics_1', 'remaining') == 1 }}" 166 | - condition: template 167 | value_template: "{{ state_attr('sensor.ics_2', 'remaining') == 1 }}" 168 | - condition: template 169 | value_template: "{{ state_attr('sensor.ics_3', 'remaining') == 1 }}" 170 | ``` 171 | 172 | and create / send some beautiful messages like this: 173 | 174 | ```yaml 175 | script: 176 | seq_trash: 177 | sequence: 178 | - service: notify.pb 179 | data_template: 180 | title: "Trash pickup tomorrow" 181 | message: > 182 | {% if is_state_attr("sensor.ics_1", "remaining",1) %} {{states.sensor.ics_1.attributes.friendly_name}} pickup tomorrow.{% endif %} 183 | {% if is_state_attr("sensor.ics_2", "remaining",1) %} {{states.sensor.ics_2.attributes.friendly_name}} pickup tomorrow.{% endif %} 184 | {% if is_state_attr("sensor.ics_3", "remaining",1) %} {{states.sensor.ics_3.attributes.friendly_name}} pickup tomorrow.{% endif %} 185 | ``` 186 | 187 | # Use-cases for skip property 188 | Show the next n-Events. Simply by creating three sensors with `n_skip:0 / n_skip:1 / n_skip:2`
189 | Setting 'description_in_state: True` will also show the title. 190 | 191 | 192 | ![Example](img_feiertage.png) 193 | 194 | Or list the next sport events 195 | 196 | ![Example](img_fussi.png) 197 | 198 | # Advance feature 199 | Reoccuring events, events at the same time, skippig events (EXDATE) ... all that can have quite some complexity to it. 200 | 201 | ![Example](img_complex_cal.png) 202 | 203 | The image above shows my test calendar (year = 2030! thus all lookahead must be >> 365) 204 | 205 | ## Regular (with grouping) 206 | ```yaml 207 | sensor: 208 | - platform: ics 209 | name: "regular" 210 | url: https://calendar.google.com/calendar/... 211 | id: 3 212 | timeformat: "%A, %d.%m.%Y" 213 | lookahead: 36500 214 | startswith: "" 215 | show_blank: "" 216 | force_update: 0 217 | show_remaining: True 218 | show_ongoing: False 219 | group_events: True 220 | n_skip: 0 221 | description_in_state: False 222 | ``` 223 | Without advance options (all defaults) we'll get a sensor, showing both event1 and event3 because grouping is enabled (`group_events: True`). 224 | 225 | ![Example](img_1_reg.png) 226 | 227 | `Setting group_event: False` would either show event1 or event3 (depends on their order in the ics file) 228 | 229 | ## Skip 3 next events 230 | 231 | ```yaml 232 | - platform: ics 233 | name: "skip 3, no blank" 234 | url: https://calendar.google.com/calendar/... 235 | id: 5 236 | timeformat: "%d.%m. " 237 | lookahead: 36500 238 | startswith: "" 239 | show_blank: "" 240 | force_update: 0 241 | show_remaining: False 242 | show_ongoing: False 243 | group_events: True 244 | n_skip: 3 245 | description_in_state: True 246 | ``` 247 | This configuration will show 'allday'. `group_events` will combine the "event1/event3". `show_blank` is not set, so the event without title will be ignored. 248 | 249 | ## Skip 3 next events but show blank events 250 | ```yaml 251 | - platform: ics 252 | name: "skip 3, incl blank" 253 | url: https://calendar.google.com/calendar/... 254 | id: 6 255 | timeformat: "%A, %d.%m.%Y" 256 | lookahead: 36500 257 | startswith: "" 258 | show_blank: "Hallo" 259 | force_update: 0 260 | show_remaining: False 261 | show_ongoing: False 262 | group_events: True 263 | n_skip: 3 264 | description_in_state: False 265 | ``` 266 | Setting `show_blank: 'Hallo'` will add the previous empty event to the list and thus show 'Hallo' instead of 'allday' 267 | 268 | ## Filter and Skip 269 | ```yaml 270 | - platform: ics 271 | name: "second reoccur" 272 | url: https://calendar.google.com/calendar/... 273 | id: 8 274 | timeformat: "%A, %d.%m.%Y" 275 | lookahead: 36500 276 | startswith: "reocc" 277 | show_blank: "" 278 | force_update: 0 279 | show_remaining: True 280 | show_ongoing: False 281 | group_events: True 282 | n_skip: 1 283 | description_in_state: False 284 | ``` 285 | This will only look at events that `startswith: 'reocc'`. The first occurance on the 7.1.2030 will be skipped (`n_skip:1`), [the exdate will drop the 8.1.2030] and thus 9.1.2030 will be shown. 286 | -------------------------------------------------------------------------------- /custom_components/isc/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide the initial setup.""" 2 | import logging 3 | from integrationhelper.const import CC_STARTUP_VERSION 4 | from .const import * 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | 8 | 9 | async def async_setup(hass, config): 10 | """Provide Setup of platform.""" 11 | _LOGGER.info( 12 | CC_STARTUP_VERSION.format(name=DOMAIN, version=VERSION, issue_link=ISSUE_URL) 13 | ) 14 | return True 15 | 16 | 17 | async def async_setup_entry(hass, config_entry): 18 | """Set up this integration using UI/YAML.""" 19 | hass.config_entries.async_update_entry( 20 | config_entry, 21 | data=ensure_config(config_entry.data, hass) 22 | ) 23 | config_entry.add_update_listener(async_update_options) 24 | 25 | # Add sensor 26 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 27 | return True 28 | 29 | 30 | async def async_remove_entry(hass, config_entry): 31 | """Handle removal of an entry.""" 32 | try: 33 | await hass.config_entries.async_forward_entry_unload(config_entry, PLATFORM) 34 | _LOGGER.info( 35 | "Successfully removed sensor from the ICS integration" 36 | ) 37 | except ValueError: 38 | pass 39 | 40 | 41 | async def async_update_options(hass, config_entry): 42 | hass.config_entries.async_update_entry(config_entry, data=config_entry.options) -------------------------------------------------------------------------------- /custom_components/isc/config_flow.py: -------------------------------------------------------------------------------- 1 | """Provide the config flow.""" 2 | from homeassistant.core import callback 3 | from homeassistant import config_entries 4 | import voluptuous as vol 5 | import logging 6 | from .const import * 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | @config_entries.HANDLERS.register(DOMAIN) 12 | class IcsFlowHandler(config_entries.ConfigFlow): 13 | """Provide the initial setup.""" 14 | 15 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 16 | VERSION = 1 17 | 18 | def __init__(self): 19 | """Provide the init function of the config flow.""" 20 | # Called once the flow is started by the user 21 | self._errors = {} 22 | 23 | # will be called by sending the form, until configuration is done 24 | async def async_step_user(self, user_input=None): # pylint: disable=unused-argument 25 | """Provide the first page of the config flow.""" 26 | self._errors = {} 27 | if user_input is not None: 28 | # there is user input, check and save if valid (see const.py) 29 | self._errors = await check_data(user_input, self.hass) 30 | if self._errors == {}: 31 | self.data = user_input 32 | return await self.async_step_finish() 33 | # no user input, or error. Show form 34 | return self.async_show_form(step_id="user", data_schema=vol.Schema(create_form(1, user_input, self.hass)), errors=self._errors) 35 | 36 | # will be called by sending the form, until configuration is done 37 | async def async_step_finish(self, user_input=None): # pylint: disable=unused-argument 38 | """Provide the second page of the config flow.""" 39 | self._errors = {} 40 | if user_input is not None: 41 | # there is user input, check and save if valid (see const.py) 42 | self._errors = await check_data(user_input, self.hass) 43 | if self._errors == {}: 44 | user_input.update(self.data) 45 | return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) 46 | # no user input, or error. Show form 47 | return self.async_show_form(step_id="finish", data_schema=vol.Schema(create_form(2, user_input, self.hass)), errors=self._errors) 48 | 49 | # TODO .. what is this good for? 50 | async def async_step_import(self, user_input): # pylint: disable=unused-argument 51 | """Import a config entry. 52 | 53 | Special type of import, we're not actually going to store any data. 54 | Instead, we're going to rely on the values that are in config file. 55 | """ 56 | if self._async_current_entries(): 57 | return self.async_abort(reason="single_instance_allowed") 58 | 59 | return self.async_create_entry(title="configuration.yaml", data={}) 60 | 61 | @staticmethod 62 | @callback 63 | def async_get_options_flow(config_entry): 64 | """Call back to start the change flow.""" 65 | return OptionsFlowHandler(config_entry) 66 | 67 | 68 | class OptionsFlowHandler(config_entries.OptionsFlow): 69 | """Change an entity via GUI.""" 70 | 71 | def __init__(self, config_entry): 72 | """Set initial parameter to grab them later on.""" 73 | # store old entry for later 74 | self.data = {} 75 | self.data.update(config_entry.data.items()) 76 | self.own_id = config_entry.data[CONF_ID] 77 | 78 | # will be called by sending the form, until configuration is done 79 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 80 | """Call this as first page.""" 81 | self._errors = {} 82 | if user_input is not None: 83 | # there is user input, check and save if valid (see const.py) 84 | self._errors = await check_data(user_input, self.hass, self.own_id) 85 | if self._errors == {}: 86 | self.data.update(user_input) 87 | return await self.async_step_finish() 88 | elif self.data is not None: 89 | # if we came straight from init 90 | user_input = self.data 91 | # no user input, or error. Show form 92 | return self.async_show_form(step_id="init", data_schema=vol.Schema(create_form(1, user_input, self.hass)), errors=self._errors) 93 | 94 | # will be called by sending the form, until configuration is done 95 | async def async_step_finish(self, user_input=None): # pylint: disable=unused-argument 96 | """Call this as second page.""" 97 | self._errors = {} 98 | if user_input is not None: 99 | self.data.update(user_input) 100 | # there is user input, check and save if valid (see const.py) 101 | self._errors = await check_data(user_input, self.hass, self.own_id) 102 | if self._errors == {}: 103 | return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) 104 | # no user input, or error. Show form 105 | return self.async_show_form(step_id="finish", data_schema=vol.Schema(create_form(2, self.data, self.hass)), errors=self._errors) 106 | -------------------------------------------------------------------------------- /custom_components/isc/const.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT 2 | from homeassistant.helpers.entity import async_generate_entity_id 3 | import homeassistant.helpers.config_validation as cv 4 | import voluptuous as vol 5 | from functools import partial 6 | import traceback 7 | import logging 8 | import datetime 9 | from tzlocal import get_localzone 10 | 11 | from collections import OrderedDict 12 | from icalendar import Calendar 13 | from urllib.request import urlopen, Request 14 | import requests 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | # generals 19 | DOMAIN = "ics" 20 | PLATFORM = "sensor" 21 | PLATFORMS = ["sensor"] 22 | VERSION = "1.2.0" 23 | ISSUE_URL = "https://github.com/koljawindeler/ics/issues" 24 | 25 | # configuration 26 | CONF_ICON = "icon" 27 | CONF_ICS_URL = "url" 28 | CONF_NAME = "name" 29 | CONF_ID = "id" 30 | CONF_TIMEFORMAT = "timeformat" 31 | CONF_LOOKAHEAD = "lookahead" 32 | CONF_SW = "startswith" 33 | CONF_CONTAINS = "contains" 34 | CONF_REGEX = "regex" 35 | CONF_SHOW_BLANK = "show_blank" 36 | CONF_FORCE_UPDATE = "force_update" 37 | CONF_SHOW_REMAINING = "show_remaining" 38 | CONF_SHOW_ONGOING = "show_ongoing" 39 | CONF_GROUP_EVENTS = "group_events" 40 | CONF_N_SKIP = "n_skip" 41 | CONF_DESCRIPTION_IN_STATE = "description_in_state" 42 | CONF_USER_AGENT = "user_agent" 43 | 44 | 45 | # defaults 46 | DEFAULT_ICON = 'mdi:calendar' 47 | DEFAULT_NAME = "ics_sensor" 48 | DEFAULT_ID = 1 49 | DEFAULT_SW = "" 50 | DEFAULT_CONTAINS = "" 51 | DEFAULT_REGEX = ".*" 52 | DEFAULT_TIMEFORMAT = "%A, %d.%m.%Y" 53 | DEFAULT_LOOKAHEAD = 365 54 | DEFAULT_SHOW_BLANK = "" 55 | DEFAULT_FORCE_UPDATE = 0 56 | DEFAULT_SHOW_REMAINING = True 57 | DEFAULT_SHOW_ONGOING = False 58 | DEFAULT_GROUP_EVENTS = True 59 | DEFAULT_N_SKIP = 0 60 | DEFAULT_DESCRIPTION_IN_STATE = False 61 | DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36' 62 | 63 | # error 64 | ERROR_URL = "invalid_url" 65 | ERROR_ICS = "invalid_ics" 66 | ERROR_TIMEFORMAT = "invalid_timeformat" 67 | ERROR_SMALL_ID = "invalid_small_id" 68 | ERROR_SMALL_LOOKAHEAD = "invalid_lookahead" 69 | ERROR_ID_NOT_UNIQUE = "id_not_unique" 70 | ERROR_NEGATIVE_SKIP = "skip_negative" 71 | 72 | # extend schema to load via YAML 73 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 74 | vol.Required(CONF_ICS_URL): cv.string, 75 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 76 | vol.Optional(CONF_ID, default=DEFAULT_ID): vol.Coerce(int), 77 | vol.Optional(CONF_TIMEFORMAT, default=DEFAULT_TIMEFORMAT): cv.string, 78 | vol.Optional(CONF_SW, default=DEFAULT_SW): cv.string, 79 | vol.Optional(CONF_CONTAINS, default=DEFAULT_CONTAINS): cv.string, 80 | vol.Optional(CONF_REGEX, default=DEFAULT_REGEX): cv.string, 81 | vol.Optional(CONF_LOOKAHEAD, default=DEFAULT_LOOKAHEAD): vol.Coerce(int), 82 | vol.Optional(CONF_SHOW_BLANK, default=DEFAULT_SHOW_BLANK): cv.string, 83 | vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): vol.Coerce(int), 84 | vol.Optional(CONF_SHOW_REMAINING, default=DEFAULT_SHOW_REMAINING): cv.boolean, 85 | vol.Optional(CONF_SHOW_ONGOING, default=DEFAULT_SHOW_ONGOING): cv.boolean, 86 | vol.Optional(CONF_GROUP_EVENTS, default=DEFAULT_GROUP_EVENTS): cv.boolean, 87 | vol.Optional(CONF_N_SKIP, default=DEFAULT_N_SKIP): vol.Coerce(int), 88 | vol.Optional(CONF_DESCRIPTION_IN_STATE, default=DEFAULT_DESCRIPTION_IN_STATE): cv.boolean, 89 | vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.string, 90 | vol.Optional(CONF_USER_AGENT, default=""): cv.string, 91 | }) 92 | 93 | 94 | def get_next_id(hass): 95 | """Provide the next unused id.""" 96 | if(hass is None): 97 | return 1 98 | for i in range(1, 999): 99 | if(async_generate_entity_id(ENTITY_ID_FORMAT, "ics_" + str(i), hass=hass) == PLATFORM + ".ics_" + str(i)): 100 | return i 101 | return 999 102 | 103 | 104 | def ensure_config(user_input, hass): 105 | """Make sure that needed Parameter exist and are filled with default if not.""" 106 | out = {} 107 | out[CONF_NAME] = "" 108 | out[CONF_ICS_URL] = "" 109 | out[CONF_TIMEFORMAT] = DEFAULT_TIMEFORMAT 110 | out[CONF_SW] = DEFAULT_SW 111 | out[CONF_CONTAINS] = DEFAULT_CONTAINS 112 | out[CONF_REGEX] = DEFAULT_REGEX 113 | out[CONF_LOOKAHEAD] = DEFAULT_LOOKAHEAD 114 | out[CONF_SHOW_BLANK] = DEFAULT_SHOW_BLANK 115 | out[CONF_FORCE_UPDATE] = DEFAULT_FORCE_UPDATE 116 | out[CONF_SHOW_REMAINING] = DEFAULT_SHOW_REMAINING 117 | out[CONF_SHOW_ONGOING] = DEFAULT_SHOW_ONGOING 118 | out[CONF_GROUP_EVENTS] = DEFAULT_GROUP_EVENTS 119 | out[CONF_N_SKIP] = DEFAULT_N_SKIP 120 | out[CONF_DESCRIPTION_IN_STATE] = DEFAULT_DESCRIPTION_IN_STATE 121 | out[CONF_ICON] = DEFAULT_ICON 122 | out[CONF_USER_AGENT] = DEFAULT_USER_AGENT 123 | out[CONF_ID] = get_next_id(hass) 124 | 125 | if user_input is not None: 126 | if CONF_NAME in user_input: 127 | out[CONF_NAME] = user_input[CONF_NAME] 128 | if CONF_ICS_URL in user_input: 129 | out[CONF_ICS_URL] = user_input[CONF_ICS_URL] 130 | if CONF_ID in user_input: 131 | out[CONF_ID] = user_input[CONF_ID] 132 | if CONF_TIMEFORMAT in user_input: 133 | out[CONF_TIMEFORMAT] = user_input[CONF_TIMEFORMAT] 134 | if CONF_SW in user_input: 135 | out[CONF_SW] = user_input[CONF_SW] 136 | if(out[CONF_SW] == " "): 137 | out[CONF_SW] = "" 138 | if CONF_CONTAINS in user_input: 139 | out[CONF_CONTAINS] = user_input[CONF_CONTAINS] 140 | if(out[CONF_CONTAINS] == " "): 141 | out[CONF_CONTAINS] = "" 142 | if CONF_REGEX in user_input: 143 | out[CONF_REGEX] = user_input[CONF_REGEX] 144 | if(out[CONF_REGEX] == " "): 145 | out[CONF_REGEX] = "" 146 | if CONF_LOOKAHEAD in user_input: 147 | out[CONF_LOOKAHEAD] = user_input[CONF_LOOKAHEAD] 148 | if CONF_SHOW_REMAINING in user_input: 149 | out[CONF_SHOW_REMAINING] = user_input[CONF_SHOW_REMAINING] 150 | if CONF_SHOW_ONGOING in user_input: 151 | out[CONF_SHOW_ONGOING] = user_input[CONF_SHOW_ONGOING] 152 | if CONF_SHOW_BLANK in user_input: 153 | out[CONF_SHOW_BLANK] = user_input[CONF_SHOW_BLANK] 154 | if(out[CONF_SHOW_BLANK] == " "): 155 | out[CONF_SHOW_BLANK] = "" 156 | if CONF_FORCE_UPDATE in user_input: 157 | out[CONF_FORCE_UPDATE] = user_input[CONF_FORCE_UPDATE] 158 | if CONF_GROUP_EVENTS in user_input: 159 | out[CONF_GROUP_EVENTS] = user_input[CONF_GROUP_EVENTS] 160 | if CONF_N_SKIP in user_input: 161 | out[CONF_N_SKIP] = user_input[CONF_N_SKIP] 162 | if CONF_DESCRIPTION_IN_STATE in user_input: 163 | out[CONF_DESCRIPTION_IN_STATE] = user_input[CONF_DESCRIPTION_IN_STATE] 164 | if CONF_ICON in user_input: 165 | out[CONF_ICON] = user_input[CONF_ICON] 166 | if CONF_USER_AGENT in user_input: 167 | out[CONF_USER_AGENT] = user_input[CONF_USER_AGENT] 168 | return out 169 | 170 | 171 | # helper to validate user input 172 | async def check_data(user_input, hass, own_id=None): 173 | """Check validity of the provided date.""" 174 | ret = {} 175 | if(CONF_ICS_URL in user_input): 176 | try: 177 | cal_string = await async_load_data(hass, user_input[CONF_ICS_URL], user_input[CONF_USER_AGENT]) 178 | try: 179 | Calendar.from_ical(cal_string) 180 | except Exception: 181 | _LOGGER.error(traceback.format_exc()) 182 | ret["base"] = ERROR_ICS 183 | return ret 184 | except Exception: 185 | _LOGGER.error(traceback.format_exc()) 186 | ret["base"] = ERROR_URL 187 | return ret 188 | 189 | if(CONF_TIMEFORMAT in user_input): 190 | try: 191 | datetime.datetime.now(get_localzone()).strftime(user_input[CONF_TIMEFORMAT]) 192 | except Exception: 193 | _LOGGER.error(traceback.format_exc()) 194 | ret["base"] = ERROR_TIMEFORMAT 195 | return ret 196 | 197 | if(CONF_ID in user_input): 198 | if(user_input[CONF_ID] < 0): 199 | _LOGGER.error("ICS: ID below zero") 200 | ret["base"] = ERROR_SMALL_ID 201 | return ret 202 | 203 | if(CONF_LOOKAHEAD in user_input): 204 | if(user_input[CONF_LOOKAHEAD] < 1): 205 | _LOGGER.error("ICS: Lookahead < 1") 206 | ret["base"] = ERROR_SMALL_LOOKAHEAD 207 | return ret 208 | 209 | if(CONF_ID in user_input): 210 | if((own_id != user_input[CONF_ID]) and (hass is not None)): 211 | if(async_generate_entity_id(ENTITY_ID_FORMAT, "ics_" + str(user_input[CONF_ID]), hass=hass) != PLATFORM + ".ics_" + str(user_input[CONF_ID])): 212 | _LOGGER.error("ICS: ID not unique") 213 | ret["base"] = ERROR_ID_NOT_UNIQUE 214 | return ret 215 | 216 | if(CONF_N_SKIP in user_input): 217 | if(user_input[CONF_N_SKIP] < 0): 218 | _LOGGER.error("ICS: Skip below zero") 219 | ret["base"] = ERROR_NEGATIVE_SKIP 220 | return ret 221 | return ret 222 | 223 | 224 | def create_form(page, user_input, hass): 225 | """Create form for UI setup.""" 226 | user_input = ensure_config(user_input, hass) 227 | 228 | data_schema = OrderedDict() 229 | if(page == 1): 230 | data_schema[vol.Required(CONF_NAME, default=user_input[CONF_NAME])] = str 231 | data_schema[vol.Required(CONF_ICS_URL, default=user_input[CONF_ICS_URL])] = str 232 | data_schema[vol.Required(CONF_ID, default=user_input[CONF_ID])] = int 233 | data_schema[vol.Optional(CONF_TIMEFORMAT, default=user_input[CONF_TIMEFORMAT])] = str 234 | data_schema[vol.Optional(CONF_SW, default=user_input[CONF_SW])] = str 235 | data_schema[vol.Optional(CONF_CONTAINS, default=user_input[CONF_CONTAINS])] = str 236 | data_schema[vol.Optional(CONF_REGEX, default=user_input[CONF_REGEX])] = str 237 | data_schema[vol.Optional(CONF_LOOKAHEAD, default=user_input[CONF_LOOKAHEAD])] = int 238 | data_schema[vol.Optional(CONF_ICON, default=user_input[CONF_ICON])] = str 239 | data_schema[vol.Optional(CONF_USER_AGENT, default=user_input[CONF_USER_AGENT])] = str 240 | 241 | elif(page == 2): 242 | data_schema[vol.Optional(CONF_SHOW_BLANK, default=user_input[CONF_SHOW_BLANK])] = str 243 | data_schema[vol.Optional(CONF_FORCE_UPDATE, default=user_input[CONF_FORCE_UPDATE])] = int 244 | data_schema[vol.Optional(CONF_N_SKIP, default=user_input[CONF_N_SKIP])] = int 245 | data_schema[vol.Optional(CONF_SHOW_REMAINING, default=user_input[CONF_SHOW_REMAINING])] = bool 246 | data_schema[vol.Optional(CONF_SHOW_ONGOING, default=user_input[CONF_SHOW_ONGOING])] = bool 247 | data_schema[vol.Optional(CONF_GROUP_EVENTS, default=user_input[CONF_GROUP_EVENTS])] = bool 248 | data_schema[vol.Optional(CONF_DESCRIPTION_IN_STATE, default=user_input[CONF_DESCRIPTION_IN_STATE])] = bool 249 | return data_schema 250 | 251 | 252 | def _load_data(url,user_agent): 253 | """Load data from URL, exported to const to call it from sensor and from config_flow.""" 254 | if(url.lower().startswith("file://")): 255 | req = Request(url=url, data=None, headers={'User-Agent': user_agent}) 256 | return urlopen(req).read().decode('ISO-8859-1') 257 | return requests.get(url, headers={'User-Agent': user_agent}, allow_redirects=True).content 258 | 259 | async def async_load_data(hass, url, user_agent): 260 | """Load data from URL, exported to const to call it from sensor and from config_flow.""" 261 | return await hass.async_add_executor_job(_load_data, url, user_agent) 262 | -------------------------------------------------------------------------------- /custom_components/isc/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ics", 3 | "name": "ics", 4 | "documentation": "https://github.com/KoljaWindeler/ics/blob/master/README.md", 5 | "config_flow": true, 6 | "version": "20250114.01", 7 | "requirements": [ 8 | "recurring-ical-events", 9 | "icalendar>=6.0.0", 10 | "tzlocal", 11 | "integrationhelper", 12 | "voluptuous", 13 | "python-dateutil>2.7.3", 14 | "pytz" 15 | ], 16 | "dependencies": [], 17 | "codeowners": [ 18 | "@KoljaWindeler", 19 | "@joemcc-90" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /custom_components/isc/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom component to grab ICS files. 3 | 4 | @ Author : Kolja Windeler 5 | @ Date : 06/02/2020 6 | @ Description : Grabs an ics file and finds next event 7 | @ Notes: Copy this file and place it in your 8 | "Home Assistant Config folder\\custom_components\\sensor\" folder. 9 | """ 10 | import logging 11 | from homeassistant.helpers.entity import Entity, async_generate_entity_id 12 | from homeassistant.components.sensor import ENTITY_ID_FORMAT 13 | from homeassistant.const import (CONF_NAME) 14 | 15 | 16 | from tzlocal import get_localzone 17 | import icalendar 18 | import recurring_ical_events 19 | import pytz 20 | import datetime 21 | import traceback 22 | from .const import * 23 | import re 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 29 | """Run setup via YAML.""" 30 | _LOGGER.debug("Config via YAML") 31 | if(config is not None): 32 | async_add_entities([ics_Sensor(hass, config)], True) 33 | 34 | 35 | async def async_setup_entry(hass, config, async_add_devices): 36 | """Run setup via Storage.""" 37 | _LOGGER.debug("Config via Storage/UI") 38 | if(len(config.data) > 0): 39 | async_add_devices([ics_Sensor(hass, config.data)], True) 40 | 41 | 42 | class ics_Sensor(Entity): 43 | """Representation of a Sensor.""" 44 | 45 | def __init__(self, hass, config): 46 | """Initialize the sensor.""" 47 | self._state_attributes = None 48 | self._state = None 49 | 50 | self._url = config.get(CONF_ICS_URL) 51 | self._name = config.get(CONF_NAME) 52 | self._sw = config.get(CONF_SW) 53 | self._contains = config.get(CONF_CONTAINS) 54 | self._regex = config.get(CONF_REGEX) 55 | self._timeformat = config.get(CONF_TIMEFORMAT) 56 | self._lookahead = config.get(CONF_LOOKAHEAD) 57 | self._show_blank = config.get(CONF_SHOW_BLANK) 58 | self._force_update = config.get(CONF_FORCE_UPDATE) 59 | self._show_remaining = config.get(CONF_SHOW_REMAINING) 60 | self._show_ongoing = config.get(CONF_SHOW_ONGOING) 61 | self._group_events = config.get(CONF_GROUP_EVENTS) 62 | self._n_skip = config.get(CONF_N_SKIP) 63 | self._description_in_state = config.get(CONF_DESCRIPTION_IN_STATE) 64 | self._icon = config.get(CONF_ICON) 65 | self._user_agent = config.get(CONF_USER_AGENT) 66 | 67 | _LOGGER.debug("ICS config: ") 68 | _LOGGER.debug("\tname: " + self._name) 69 | _LOGGER.debug("\tID: " + str(config.get(CONF_ID))) 70 | _LOGGER.debug("\turl: " + self._url) 71 | _LOGGER.debug("\tsw: " + self._sw) 72 | _LOGGER.debug("\tcontains: " + self._contains) 73 | _LOGGER.debug("\tregex: " + self._regex) 74 | _LOGGER.debug("\ttimeformat: " + self._timeformat) 75 | _LOGGER.debug("\tlookahead: " + str(self._lookahead)) 76 | _LOGGER.debug("\tshow_blank: " + str(self._show_blank)) 77 | _LOGGER.debug("\tforce_update: " + str(self._force_update)) 78 | _LOGGER.debug("\tshow_remaining: " + str(self._show_remaining)) 79 | _LOGGER.debug("\tshow_ongoing: " + str(self._show_ongoing)) 80 | _LOGGER.debug("\tgroup_events: " + str(self._group_events)) 81 | _LOGGER.debug("\tn_skip: " + str(self._n_skip)) 82 | _LOGGER.debug("\tdescription_in_state: " + str(self._description_in_state)) 83 | _LOGGER.debug("\ticon: " + str(self._icon)) 84 | _LOGGER.debug("\tuser_agent: " + str(self._user_agent)) 85 | 86 | self._lastUpdate = -1 87 | self.ics = { 88 | 'extra': { 89 | 'start': None, 90 | 'end': None, 91 | 'remaining': -999, 92 | 'description': "-", 93 | 'location': '-', 94 | 'last_updated': None, 95 | 'reload_at': None, 96 | }, 97 | 'pickup_date': "-", 98 | } 99 | 100 | self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, "ics_" + str(config.get(CONF_ID)), hass=hass) 101 | self._attr_unique_id = self.entity_id 102 | 103 | @property 104 | def name(self): 105 | """Return the name of the sensor.""" 106 | return self._name 107 | 108 | @property 109 | def extra_state_attributes(self): 110 | """Return the state attributes.""" 111 | return self._state_attributes 112 | 113 | @property 114 | def state(self): 115 | """Return the state of the sensor.""" 116 | return self._state 117 | 118 | @property 119 | def icon(self): 120 | """Return the icon to use in the frontend.""" 121 | return self._icon 122 | 123 | def fix_text(self, s): 124 | """Remove Umlaute.""" 125 | s = ''.join(e for e in s if (e.isalnum() or e == ' ')) 126 | s = s.replace(chr(195), 'u') 127 | s = s.replace(chr(188), 'e') 128 | return s 129 | 130 | def exc(self): 131 | """Print nicely formated exception.""" 132 | _LOGGER.error("\n\n============= ICS Integration Error ================") 133 | _LOGGER.error("unfortunately ICS hit an error, please open a ticket at") 134 | _LOGGER.error("https://github.com/KoljaWindeler/ics/issues") 135 | _LOGGER.error("and paste the following output:\n") 136 | _LOGGER.error(traceback.format_exc()) 137 | _LOGGER.error("\nthanks, Kolja") 138 | _LOGGER.error("============= ICS Integration Error ================\n\n") 139 | 140 | def check_fix_date_tz(self, event): 141 | """Make sure all elements are timezone aware datetimes.""" 142 | if(isinstance(event.dt, datetime.date) and not(isinstance(event.dt, datetime.datetime))): 143 | event.dt = datetime.datetime(event.dt.year, event.dt.month, event.dt.day) 144 | try: 145 | if(event.dt.tzinfo is None or event.dt.utcoffset() is None): 146 | event.dt = event.dt.replace(tzinfo=get_localzone()) 147 | except Exception: 148 | pass 149 | return event 150 | 151 | def check_fix_rrule(self, calendar): 152 | """Make sure that the UNITL date of the RRULE is the same type as DTSTART is. 153 | 154 | otherwise recurring_ical_events will crash. 155 | """ 156 | fix = 0 157 | for event in calendar.walk('vevent'): 158 | if("RRULE" in event): 159 | if("UNTIL" in event["RRULE"]): 160 | # different 161 | if(type(event["DTSTART"].dt) != type(event["RRULE"]["UNTIL"][0])): 162 | if(type(event["DTSTART"].dt) == datetime.datetime): 163 | d = event["RRULE"]["UNTIL"][0] 164 | event["RRULE"]["UNTIL"][0] = datetime.datetime(d.year, d.month, d.day) 165 | else: 166 | event["RRULE"]["UNTIL"][0] = event["RRULE"]["UNTIL"][0].date() 167 | fix += 1 168 | # tz fixing 169 | if(type(event["RRULE"]["UNTIL"][0]) == datetime.datetime): 170 | event["RRULE"]["UNTIL"][0] = event["RRULE"]["UNTIL"][0].replace(tzinfo=datetime.timezone.utc) 171 | return fix 172 | 173 | def matches_regex(self, summary): 174 | """Check for regex Event""" 175 | match = re.fullmatch(self._regex, summary) 176 | return match 177 | 178 | async def get_data(self): 179 | """Update the actual data.""" 180 | try: 181 | cal_string = await async_load_data(self.hass, self._url, self._user_agent) 182 | icalendar.use_pytz() 183 | cal = icalendar.Calendar.from_ical(cal_string) 184 | 185 | # fix RRULE 186 | _LOGGER.debug(f"fixed {self.check_fix_rrule(cal)} RRule dates") 187 | 188 | # define calendar range 189 | start_date = datetime.datetime.now().replace(minute=0, hour=0, second=0, microsecond=0) 190 | end_date = start_date + datetime.timedelta(days=self._lookahead) 191 | 192 | # unfold calendar 193 | reoccuring_events = recurring_ical_events.of(cal).between(start_date, end_date) 194 | 195 | # make all item TZ aware datetime 196 | for event in reoccuring_events: 197 | if("DTSTART" in event): 198 | event["DTSTART"] = self.check_fix_date_tz(event["DTSTART"]) 199 | if("DTEND" in event): 200 | event["DTEND"] = self.check_fix_date_tz(event["DTEND"]) 201 | 202 | try: 203 | reoccuring_events = sorted(reoccuring_events, key=lambda x: x["DTSTART"].dt, reverse=False) 204 | except Exception: 205 | self.exc() 206 | 207 | self.ics['pickup_date'] = "no next event" 208 | self.ics['extra']['last_updated'] = datetime.datetime.now(get_localzone()).replace(microsecond=0) 209 | self.ics['extra']['reload_at'] = None 210 | self.ics['extra']['start'] = None 211 | self.ics['extra']['end'] = None 212 | self.ics['extra']['remaining'] = -999 213 | self.ics['extra']['description'] = "-" 214 | self.ics['extra']['location'] = "-" 215 | 216 | et = None 217 | skip_et = None 218 | n_skip = self._n_skip 219 | skip_reload_at = None 220 | event_date = None 221 | event_end_date = None 222 | 223 | # get now to check if events have started 224 | now = datetime.datetime.now(get_localzone()) 225 | 226 | if(len(reoccuring_events) > 0): 227 | for e in reoccuring_events: 228 | # load start / end 229 | event_date = e["DTSTART"].dt 230 | if("DTEND" in e): 231 | event_end_date = e["DTEND"].dt 232 | else: 233 | event_end_date = event_date 234 | 235 | # handle empty or non existing summary field 236 | event_summary = self.fix_text(self._show_blank) 237 | if("SUMMARY" in e): 238 | if(self.fix_text(e["SUMMARY"]) != ""): 239 | event_summary = self.fix_text(e["SUMMARY"]) 240 | 241 | if(event_summary): 242 | if(event_summary.lower().startswith(self.fix_text(self._sw).lower()) and 243 | event_summary.lower().find(self.fix_text(self._contains).lower())>=0 and 244 | self.matches_regex(event_summary)): 245 | if((event_date > now) or (self._show_ongoing and event_end_date > now)): 246 | # logic to skip events, but save certain details, 247 | # e.g. reload / and timeslot for grouping 248 | if(n_skip > 0 or skip_et is not None): 249 | # note first event_date as reload time 250 | if(skip_reload_at is None): 251 | skip_reload_at = event_date 252 | # if this event is the at the exact same time 253 | # as the last and we're grouping 254 | if(skip_et is not None and skip_et == event_date and self._group_events): 255 | # increase it temporary, will besically abolish the next line 256 | n_skip += 1 257 | n_skip -= 1 258 | skip_et = event_date 259 | if(n_skip >= 0): 260 | continue 261 | else: 262 | skip_et = None 263 | 264 | # if we hit this timeslot the firsttime, store everything 265 | if(et is None): 266 | self.ics['pickup_date'] = event_date.strftime(self._timeformat) 267 | self.ics['extra']['remaining'] = (event_date.date() - now.date()).days 268 | self.ics['extra']['description'] = event_summary 269 | self.ics['extra']['start'] = event_date.astimezone() 270 | self.ics['extra']['end'] = event_end_date.astimezone() 271 | if("LOCATION" in e): 272 | self.ics['extra']['location'] = self.fix_text(e["LOCATION"]) 273 | et = event_date 274 | 275 | # if grouping is active and we hat it again, append 276 | elif((event_date == et) and (self._group_events)): 277 | self.ics['extra']['description'] += " / " + event_summary 278 | if("LOCATION" in e): 279 | self.ics['extra']['location'] += " / " + self.fix_text(e["LOCATION"]) 280 | # store earliest end time 281 | if(self.ics['extra']['end'] > e["DTEND"].dt): 282 | self.ics['extra']['end'] = e["DTEND"].dt.astimezone() 283 | 284 | # this event hat a differnt timeslot then the first, so we don't append it and end here 285 | else: 286 | break 287 | 288 | # ----------- start of reload calucation ----------- 289 | # base line for relaod is daybreak 290 | self.ics['extra']['reload_at'] = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) 291 | 292 | # check if the skip_reload at is easlier 293 | if(skip_reload_at is not None): 294 | if(skip_reload_at < self.ics['extra']['reload_at']): 295 | self.ics['extra']['reload_at'] = skip_reload_at 296 | 297 | # if we have a interval reload 298 | if(self._force_update > 0): 299 | force_reload_at = now + datetime.timedelta(seconds=self._force_update) 300 | if(force_reload_at < self.ics['extra']['reload_at']): 301 | self.ics['extra']['reload_at'] = force_reload_at 302 | 303 | # check if the next appointment is even earlier 304 | if(self._show_ongoing and event_end_date is not None): 305 | if(event_end_date < self.ics['extra']['reload_at']): 306 | self.ics['extra']['reload_at'] = event_end_date 307 | elif(event_date is not None): 308 | if(event_date < self.ics['extra']['reload_at']): 309 | self.ics['extra']['reload_at'] = event_date 310 | 311 | self.ics['extra']['reload_at'] = self.ics['extra']['reload_at'].replace(microsecond=0) 312 | # ----------- end of reload calucation ----------- 313 | except Exception: 314 | self.ics['pickup_date'] = "failure" 315 | self.exc() 316 | 317 | 318 | async def async_update(self): 319 | """Fetch new state data for the sensor. 320 | This is the only method that should fetch new data for Home Assistant. 321 | """ 322 | try: 323 | # first run 324 | if(self.ics['extra']['reload_at'] is None): 325 | await self.get_data() 326 | # check if we're past reload_at 327 | elif(self.ics['extra']['reload_at'] < datetime.datetime.now(get_localzone())): 328 | await self.get_data() 329 | 330 | # update states 331 | self._state_attributes = self.ics['extra'] 332 | self._state = self.ics['pickup_date'] 333 | if(self._show_remaining): 334 | self._state += ' (%02i)' % self.ics['extra']['remaining'] 335 | if(self._description_in_state): 336 | self._state += ' ' + self.ics['extra']['description'] 337 | except Exception: 338 | self._state = "error" 339 | self.exc() 340 | -------------------------------------------------------------------------------- /custom_components/isc/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "ICS - Calendar", 4 | "step": { 5 | "user": { 6 | "title": "ICS", 7 | "description": "Enter the sensor name and configure sensor parameters. More info on https://github.com/koljawindeler/ics#configuration-options.\n Page 1/2", 8 | "data": { 9 | "name": "Friendly name (can be changed later on) and title for the config flow (fix)", 10 | "url": "The url to the ics file. Usually http://domain/calendar.ics but can also be local file:///tmp/test.ics ", 11 | "id": "Unique number to identify your sensor later on. e.g. id=1 results in sensor.ical_1", 12 | "timeformat": "Timeformat (see http://strftime.org/).", 13 | "startswith": "Startswith Filter, see link above for more info" , 14 | "contains": "Contains Filter, see link above for more info" , 15 | "regex": "Regex Filter, see link above for more info" , 16 | "lookahead": "Lookahead", 17 | "icon": "Icon (see https://materialdesignicons.com/)." 18 | } 19 | }, 20 | "finish": { 21 | "title": "ICS", 22 | "description": "Enter the sensor name and configure sensor parameters. More info on https://github.com/koljawindeler/ics#configuration-options.\n Page 2/2", 23 | "data": { 24 | "show_blank": "Show events without title", 25 | "force_update": "Force periodical updates (sec)", 26 | "show_remaining": "Show remaining days", 27 | "show_ongoing": "Show events that are ongoing", 28 | "group_events": "Group events (show multiple)", 29 | "n_skip": "Amount of events to skip", 30 | "description_in_state": "Show description in state", 31 | "user_agent": "User Agent" 32 | } 33 | } 34 | }, 35 | "error": { 36 | "invalid_url": "The URL is not valid, could not download data.", 37 | "invalid_ics": "The downloaded file can not be parsed, not a valid ICS file.", 38 | "invalid_timeformat": "The timeformat is not valid (see http://strftime.org/)", 39 | "invalid_small_id": "The ID must be >= 0", 40 | "invalid_lookahead": "The lookahead must be >= 1", 41 | "id_not_unique": "The choosen ID is not unique", 42 | "skip_negative": "The skip value can't be negative" 43 | } 44 | }, 45 | "options": { 46 | "step": { 47 | "init": { 48 | "title": "ICS", 49 | "description": "Enter the sensor name and configure sensor parameters. More info on https://github.com/koljawindeler/ics#configuration-options.\n Page 1/2", 50 | "data": { 51 | "name": "Friendly name (can be changed later on) and title for the config flow (fix)", 52 | "url": "The url to the ics file. Usually http://domain/calendar.ics but can also be local file:///tmp/test.ics ", 53 | "id": "Unique number to identify your sensor later on. e.g. id=1 results in sensor.ical_1", 54 | "timeformat": "Timeformat (see http://strftime.org/).", 55 | "startswith": "Startswith Filter, see link above for more info" , 56 | "contains": "Contains Filter, see link above for more info" , 57 | "regex": "Regex Filter, see link above for more info" , 58 | "lookahead": "Lookahead", 59 | "icon": "Icon (see https://materialdesignicons.com/)." 60 | } 61 | }, 62 | "finish": { 63 | "title": "ICS", 64 | "description": "Enter the sensor name and configure sensor parameters. More info on https://github.com/koljawindeler/ics#configuration-options.\n Page 2/2", 65 | "data": { 66 | "show_blank": "Show events without title", 67 | "force_update": "Force periodical updates (sec)", 68 | "show_remaining": "Show remaining days", 69 | "show_ongoing": "Show events that are ongoing", 70 | "group_events": "Group events (show multiple)", 71 | "n_skip": "Amount of events to skip", 72 | "description_in_state": "Show description in state", 73 | "user_agent": "User Agent" 74 | } 75 | } 76 | }, 77 | "error": { 78 | "invalid_url": "The URL is not valid, could not download data.", 79 | "invalid_ics": "The downloaded file can not be parsed, not a valid ICS file.", 80 | "invalid_timeformat": "The timeformat is not valid (see http://strftime.org/)", 81 | "invalid_small_id": "The ID must be >= 0", 82 | "invalid_lookahead": "The lookahead must be >= 1", 83 | "id_not_unique": "The choosen ID is not unique", 84 | "skip_negative": "The skip value can't be negative" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /custom_components/isc/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "ICS - Kalender", 4 | "step": { 5 | "user": { 6 | "title": "ICS", 7 | "description": "Skriv inn sensornavnet og konfigurer sensorparametrene. Mer info på https://github.com/koljawindeler/ics#configuration-options.\n Side 1/2", 8 | "data": { 9 | "name": "Vennlig navn (kan endres senere) og tittel for konfigurasjonsflyten (fikse)", 10 | "url": "URLen til ics-filen. Vanligvis http://domene/kalender.ics, men kan også være lokal fil:///tmp/test.ics ", 11 | "id": "Unikt nummer for å identifisere sensoren din senere. f.eks. id = 1 resulterer i sensor.ical_1", 12 | "timeformat": "Tidsformat (se http://strftime.org/).", 13 | "startswith": "Startswith Filter, se lenke over for mer info" , 14 | "contains": "Contains Filter, se lenke over for mer info" , 15 | "lookahead": "Lookahead", 16 | "icon": "Ikon (se https://materialdesignicons.com/)." 17 | } 18 | }, 19 | "finish": { 20 | "title": "ICS", 21 | "description": "Skriv inn sensornavnet og konfigurer sensorparametrene. Mer info på https://github.com/koljawindeler/ics#configuration-options.\n Side 2/2", 22 | "data": { 23 | "show_blank": "Vis hendelser uten tittel", 24 | "force_update": "Tving periodiske oppdateringer (sec)", 25 | "show_remaining": "Vis gjenværende dager", 26 | "show_ongoing": "Vis hendelser som pågå", 27 | "group_events": "Gruppearrangementer (vis flere)", 28 | "n_skip": "Antall hendelser å hoppe over", 29 | "description_in_state": "Vis beskrivelse i tilstand" 30 | } 31 | } 32 | }, 33 | "error": { 34 | "invalid_url": "URL-en er ikke gyldig, kunne ikke laste ned data.", 35 | "invalid_ics": "Den nedlastede filen kan ikke analyseres, ikke en gyldig ICS-fil.", 36 | "invalid_timeformat": "Tidsformatet er ikke gyldig (se http://strftime.org/)", 37 | "invalid_small_id": "ID-en må være> = 0", 38 | "invalid_lookahead": "Utseendehodet må være> = 1", 39 | "id_not_unique": "Den valgte ID-en er ikke unik", 40 | "skip_negative": "Hopp over verdien kan ikke være negativ" 41 | } 42 | }, 43 | "options": { 44 | "step": { 45 | "init": { 46 | "title": "ICS", 47 | "description": "Skriv inn sensornavnet og konfigurer sensorparametrene. Mer info på https://github.com/koljawindeler/ics#configuration-options.\n Side 1/2", 48 | "data": { 49 | "name": "Vennlig navn (kan endres senere) og tittel for konfigurasjonsflyten (fikse)", 50 | "url": "URLen til ics-filen. Vanligvis http://domene/kalender.ics, men kan også være lokal fil:///tmp/test.ics ", 51 | "id": "Unikt nummer for å identifisere sensoren din senere. f.eks. id = 1 resulterer i sensor.ical_1", 52 | "timeformat": "Tidsformat (se http://strftime.org/).", 53 | "startswith": "Startswith Filter, se lenke over for mer info" , 54 | "contains": "Contains Filter, se lenke over for mer info" , 55 | "lookahead": "Se fremover", 56 | "icon": "Ikon (se https://materialdesignicons.com/)." 57 | } 58 | }, 59 | "finish": { 60 | "title": "ICS", 61 | "description": "Skriv inn sensornavnet og konfigurer sensorparametrene. Mer info på https://github.com/koljawindeler/ics#configuration-options.\n Side 2/2", 62 | "data": { 63 | "show_blank": "Vis hendelser uten tittel", 64 | "force_update": "Tving periodiske oppdateringer (sec)", 65 | "show_remaining": "Vis gjenværende dager", 66 | "show_ongoing": "Vis hendelser som pågår", 67 | "group_events": "Gruppearrangementer (vis flere)", 68 | "n_skip": "Antall hendelser å hoppe over", 69 | "description_in_state": "Vis beskrivelse i tilstand" 70 | } 71 | } 72 | }, 73 | "error": { 74 | "invalid_url": "URL-en er ikke gyldig, kunne ikke laste ned data.", 75 | "invalid_ics": "Den nedlastede filen kan ikke analyseres, ikke en gyldig ICS-fil.", 76 | "invalid_timeformat": "Tidsformatet er ikke gyldig (se http://strftime.org/)", 77 | "invalid_small_id": "ID-en må være> = 0", 78 | "invalid_lookahead": "Utseendehodet må være> = 1", 79 | "id_not_unique": "Den valgte ID-en er ikke unik", 80 | "skip_negative": "Hopp over verdien kan ikke være negativ" 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ICS", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "iot_class": "Cloud Polling" 6 | } 7 | -------------------------------------------------------------------------------- /img_1_reg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ics/25cdd3736a0fdbebd7ef47e9c476f2021d588e26/img_1_reg.png -------------------------------------------------------------------------------- /img_complex_cal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ics/25cdd3736a0fdbebd7ef47e9c476f2021d588e26/img_complex_cal.png -------------------------------------------------------------------------------- /img_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ics/25cdd3736a0fdbebd7ef47e9c476f2021d588e26/img_example.png -------------------------------------------------------------------------------- /img_feiertage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ics/25cdd3736a0fdbebd7ef47e9c476f2021d588e26/img_feiertage.png -------------------------------------------------------------------------------- /img_fussi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ics/25cdd3736a0fdbebd7ef47e9c476f2021d588e26/img_fussi.png --------------------------------------------------------------------------------