Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Key | Type | Required | Default | Description
`description_in_state` | `bool` | `false` | `false` | Show the title of the events in the state
`icon` | `string` | `false` | `mdi:calendar` | MDI Icon string, check https://materialdesignicons.com/

`header_name` | `string` | `false` | `""` | (Deprecated) Single header name to send with the request. Use `headers` instead.
`header_value` | `string` | `false` | `""` | (Deprecated) Value for `header_name`.
`headers` | `mapping` | `false` | `{}` | Mapping of headers to send with the request. Useful for tokens or custom auth headers.

## GUI configuration

As of 2020/04/20 config flow is supported and is the prefered way to setup the integration. (No need to restart Home-Assistant)
Expand All @@ -96,6 +100,14 @@ sensor:
id: 1
icon: "mdi:recycle"

# Example with custom header (YAML mapping)
- platform: ics
name: Kolding Calendar
url: https://koldingivapi.infovision.dk/api/publiccitizen/container/65557/collectioncalendar.ics
id: 10
headers:
publicAccessToken: __NetDialogCitizenPublicAccessToken__

- platform: ics
name: Trash
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
Expand Down Expand Up @@ -145,6 +157,25 @@ sensor:

```

## Header authentication / custom headers

You can send custom HTTP headers with the request. Preferred option (YAML) is to use the `headers` mapping. Example YAML:

```yaml
- platform: ics
name: Kolding Calendar
url: https://koldingivapi.infovision.dk/api/publiccitizen/container/65557/collectioncalendar.ics
id: 11
headers:
publicAccessToken: __NetDialogCitizenPublicAccessToken__
```

If you configure the integration via the UI there is a `Headers` field on the first page. It accepts either a JSON object or a multiline list of `Name: Value` pairs, for example:

publicAccessToken: __NetDialogCitizenPublicAccessToken__

For backward-compatibility the older `header_name` and `header_value` options are still supported but `headers` (mapping) is recommended.

# Automation

Example that executes on the day before one of the 'events'
Expand Down
5 changes: 4 additions & 1 deletion custom_components/isc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Provide the initial setup."""
import logging
from integrationhelper.const import CC_STARTUP_VERSION
try:
from integrationhelper.const import CC_STARTUP_VERSION
except Exception:
CC_STARTUP_VERSION = "{name} {version} started - {issue_link}"
from .const import *

_LOGGER = logging.getLogger(__name__)
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
126 changes: 120 additions & 6 deletions custom_components/isc/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
CONF_N_SKIP = "n_skip"
CONF_DESCRIPTION_IN_STATE = "description_in_state"
CONF_USER_AGENT = "user_agent"
CONF_HEADER_NAME = "header_name"
CONF_HEADER_VALUE = "header_value"
CONF_HEADERS = "headers"
CONF_VERBOSE_LOGGING = "verbose_logging"


# defaults
Expand Down Expand Up @@ -88,6 +92,11 @@
vol.Optional(CONF_DESCRIPTION_IN_STATE, default=DEFAULT_DESCRIPTION_IN_STATE): cv.boolean,
vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.string,
vol.Optional(CONF_USER_AGENT, default=""): cv.string,
vol.Optional(CONF_HEADER_NAME, default=""): cv.string,
vol.Optional(CONF_HEADER_VALUE, default=""): cv.string,
# YAML: accept a mapping of headers
vol.Optional(CONF_HEADERS, default={}): {cv.string: cv.string},
vol.Optional(CONF_VERBOSE_LOGGING, default=False): cv.boolean,
})


Expand Down Expand Up @@ -120,6 +129,10 @@ def ensure_config(user_input, hass):
out[CONF_DESCRIPTION_IN_STATE] = DEFAULT_DESCRIPTION_IN_STATE
out[CONF_ICON] = DEFAULT_ICON
out[CONF_USER_AGENT] = DEFAULT_USER_AGENT
out[CONF_HEADER_NAME] = ""
out[CONF_HEADER_VALUE] = ""
out[CONF_HEADERS] = {}
out[CONF_VERBOSE_LOGGING] = False
out[CONF_ID] = get_next_id(hass)

if user_input is not None:
Expand Down Expand Up @@ -165,6 +178,34 @@ def ensure_config(user_input, hass):
out[CONF_ICON] = user_input[CONF_ICON]
if CONF_USER_AGENT in user_input:
out[CONF_USER_AGENT] = user_input[CONF_USER_AGENT]
if CONF_HEADER_NAME in user_input:
out[CONF_HEADER_NAME] = user_input[CONF_HEADER_NAME]
if CONF_HEADER_VALUE in user_input:
out[CONF_HEADER_VALUE] = user_input[CONF_HEADER_VALUE]
if CONF_HEADERS in user_input:
# accept either a dict (from YAML) or a string (from UI). If string, try to parse lines or JSON.
val = user_input[CONF_HEADERS]
if isinstance(val, dict):
out[CONF_HEADERS] = val
elif isinstance(val, str):
val = val.strip()
if val == "":
out[CONF_HEADERS] = {}
else:
# try JSON first
try:
import json
out[CONF_HEADERS] = json.loads(val)
except Exception:
# parse lines like 'Name: Value'
headers = {}
for line in val.splitlines():
if ':' in line:
k, v = line.split(':', 1)
headers[k.strip()] = v.strip()
out[CONF_HEADERS] = headers
if CONF_VERBOSE_LOGGING in user_input:
out[CONF_VERBOSE_LOGGING] = user_input[CONF_VERBOSE_LOGGING]
return out


Expand All @@ -174,7 +215,34 @@ async def check_data(user_input, hass, own_id=None):
ret = {}
if(CONF_ICS_URL in user_input):
try:
cal_string = await async_load_data(hass, user_input[CONF_ICS_URL], user_input[CONF_USER_AGENT])
# build headers dict from possible sources
_headers = {}
# YAML/config may provide mapping or UI may provide a string — normalize both
if CONF_HEADERS in user_input:
val = user_input[CONF_HEADERS]
if isinstance(val, dict):
_headers.update(val)
elif isinstance(val, str):
val = val.strip()
if val != "":
# try JSON first
try:
import json
parsed = json.loads(val)
if isinstance(parsed, dict):
_headers.update(parsed)
except Exception:
# parse lines like 'Name: Value'
headers = {}
for line in val.splitlines():
if ':' in line:
k, v = line.split(':', 1)
headers[k.strip()] = v.strip()
_headers.update(headers)
# single header fields (backwards compat)
if user_input.get(CONF_HEADER_NAME):
_headers[user_input.get(CONF_HEADER_NAME)] = user_input.get(CONF_HEADER_VALUE, "")
cal_string = await async_load_data(hass, user_input[CONF_ICS_URL], user_input.get(CONF_USER_AGENT, ""), headers=_headers, verbose=user_input.get(CONF_VERBOSE_LOGGING, False))
try:
Calendar.from_ical(cal_string)
except Exception:
Expand Down Expand Up @@ -227,6 +295,15 @@ def create_form(page, user_input, hass):

data_schema = OrderedDict()
if(page == 1):
# prepare headers default: convert dict to multiline string for the form
header_default = ""
if isinstance(user_input.get(CONF_HEADERS), dict) and len(user_input.get(CONF_HEADERS))>0:
lines = []
for k, v in user_input.get(CONF_HEADERS).items():
lines.append(f"{k}: {v}")
header_default = "\n".join(lines)
elif isinstance(user_input.get(CONF_HEADERS), str):
header_default = user_input.get(CONF_HEADERS)
data_schema[vol.Required(CONF_NAME, default=user_input[CONF_NAME])] = str
data_schema[vol.Required(CONF_ICS_URL, default=user_input[CONF_ICS_URL])] = str
data_schema[vol.Required(CONF_ID, default=user_input[CONF_ID])] = int
Expand All @@ -237,6 +314,7 @@ def create_form(page, user_input, hass):
data_schema[vol.Optional(CONF_LOOKAHEAD, default=user_input[CONF_LOOKAHEAD])] = int
data_schema[vol.Optional(CONF_ICON, default=user_input[CONF_ICON])] = str
data_schema[vol.Optional(CONF_USER_AGENT, default=user_input[CONF_USER_AGENT])] = str
data_schema[vol.Optional(CONF_HEADERS, default=header_default)] = str

elif(page == 2):
data_schema[vol.Optional(CONF_SHOW_BLANK, default=user_input[CONF_SHOW_BLANK])] = str
Expand All @@ -249,13 +327,49 @@ def create_form(page, user_input, hass):
return data_schema


def _load_data(url,user_agent):
def _load_data(url,user_agent, headers=None, header_name=None, header_value=None, verbose=False):
"""Load data from URL, exported to const to call it from sensor and from config_flow."""
# prepare headers
built = {}
if headers and isinstance(headers, dict):
built.update(headers)
# User-Agent preference: explicit user_agent should override existing
if user_agent:
built['User-Agent'] = user_agent
# backward compatibility for single header fields
if header_name is not None and header_name != "" and header_value is not None:
built[header_name] = header_value
if(url.lower().startswith("file://")):
req = Request(url=url, data=None, headers={'User-Agent': user_agent})
# log curl-equivalent for debugging
if verbose:
try:
curl_parts = ["curl -sS --location --fail"]
curl_parts.append(f"'{url}'")
for k, v in built.items():
curl_parts.append(f"-H '{k}: {v}'")
curl_cmd = ' '.join(curl_parts)
_LOGGER.debug("ICS fetch (curl): %s", curl_cmd)
except Exception:
pass
req = Request(url=url, data=None, headers=built)
return urlopen(req).read().decode('ISO-8859-1')
return requests.get(url, headers={'User-Agent': user_agent}, allow_redirects=True).content
# log curl-equivalent for debugging
if verbose:
try:
curl_parts = ["curl -sS --location --fail"]
curl_parts.append(f"'{url}'")
for k, v in built.items():
curl_parts.append(f"-H '{k}: {v}'")
curl_cmd = ' '.join(curl_parts)
_LOGGER.debug("ICS fetch (curl): %s", curl_cmd)
except Exception:
pass
resp = requests.get(url, headers=built, allow_redirects=True)
# also log status code for quick diagnostics (only if verbose)
if verbose:
_LOGGER.debug("ICS fetch response: %s %s", resp.status_code, resp.headers.get('content-type'))
return resp.content

async def async_load_data(hass, url, user_agent):
async def async_load_data(hass, url, user_agent, headers=None, header_name=None, header_value=None, verbose=False):
"""Load data from URL, exported to const to call it from sensor and from config_flow."""
return await hass.async_add_executor_job(_load_data, url, user_agent)
return await hass.async_add_executor_job(_load_data, url, user_agent, headers, header_name, header_value, verbose)
2 changes: 1 addition & 1 deletion custom_components/isc/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "ics",
"documentation": "https://github.com/KoljaWindeler/ics/blob/master/README.md",
"config_flow": true,
"version": "20250114.01",
"version": "20260105.03",
"requirements": [
"recurring-ical-events",
"icalendar>=6.0.0",
Expand Down
10 changes: 9 additions & 1 deletion custom_components/isc/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ def __init__(self, hass, config):
self._description_in_state = config.get(CONF_DESCRIPTION_IN_STATE)
self._icon = config.get(CONF_ICON)
self._user_agent = config.get(CONF_USER_AGENT)
# headers: support both a mapping `headers` or single header_name/header_value for backward compatibility
self._headers = config.get(CONF_HEADERS) if config.get(CONF_HEADERS) is not None else {}
# include single header fields if present
if config.get(CONF_HEADER_NAME):
self._headers[config.get(CONF_HEADER_NAME)] = config.get(CONF_HEADER_VALUE, "")
self._verbose_logging = config.get(CONF_VERBOSE_LOGGING, False)

_LOGGER.debug("ICS config: ")
_LOGGER.debug("\tname: " + self._name)
Expand All @@ -82,6 +88,8 @@ def __init__(self, hass, config):
_LOGGER.debug("\tdescription_in_state: " + str(self._description_in_state))
_LOGGER.debug("\ticon: " + str(self._icon))
_LOGGER.debug("\tuser_agent: " + str(self._user_agent))
_LOGGER.debug("\theaders: " + str(self._headers))
_LOGGER.debug("\tverbose_logging: " + str(self._verbose_logging))

self._lastUpdate = -1
self.ics = {
Expand Down Expand Up @@ -178,7 +186,7 @@ def matches_regex(self, summary):
async def get_data(self):
"""Update the actual data."""
try:
cal_string = await async_load_data(self.hass, self._url, self._user_agent)
cal_string = await async_load_data(self.hass, self._url, self._user_agent, headers=self._headers, verbose=self._verbose_logging)
icalendar.use_pytz()
cal = icalendar.Calendar.from_ical(cal_string)

Expand Down