diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff70c2c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +custom_components/seatconnect/__pycache__ diff --git a/README.md b/README.md index 5fbea47..b9f1b54 100644 --- a/README.md +++ b/README.md @@ -4,43 +4,54 @@ # Seat Connect - A Home Assistant custom component for Seat Connect -# v1.0.32 -**WARNING!** -***Version 1.0.30 and later has undergone major code changes since release 1.0.27. -If you are updating, be sure to backup all your data*** -Major changes are entity names and climate entities are removed. -Supported features are automatically discovered through API calls, this hasn't been tested on all cars and might prove unreliable for some. - -## This is fork of [robinostlund/homeassistant-volkswagencarnet](https://github.com/robinostlund/homeassistant-volkswagencarnet) modified to support Seat Connect through native app API (API calls directly to vwg-connect services) -This integration for Home Assistant will fetch data from Seat Connect servers related to your Seat Connect enabled car. +# v1.1.0 +## This is fork of [lendy007/homeassistant-skodaconnect](https://github.com/lendy007/homeassistant-skodaconnect) modified to support Seat +This integration for Home Assistant will fetch data from VAG connect servers related to your Seat Connect enabled car. Seat Connect never fetch data directly from car, the car sends updated data to VAG servers on specific events such as lock/unlock, charging events, climatisation events and when vehicle is parked. The integration will then fetch this data from the servers. When vehicle actions fails or return with no response, a force refresh might help. This will trigger a "wake up" call from VAG servers to the car. -The scan_interval is how often the integration should fetch data from the servers, if there's no new data from the car then entities won't be updated- - -### What is working -- odometer and service info -- fuel level, range, adblue level -- lock, windows, trunk, hood, sunroof and door status -- last trip info -- position - gps coordinates, if vehicle is moving, time parked -- electric engine related information - charging, battery level, plug connected and more -- electric climatisation and window_heater information -- start/stop auxiliary climatisation for PHEV cars -- start/stop electric climatisation and window_heater -- lock/unlock car -- parking heater heating/ventilation (for non-PHEV cars) -- requests information - latest status, requests remaining until throttled -- device tracker - entity is set to 'not_home' when car is moving -- trigger data refresh - this will trigger a wake up call so the car sends new data - -### What is NOT working / under development -- climate entitites has been removed since they didn't map very well for requests to Seat Connect API. -- switches doesn't immediately update "request reulsts" and "request_in_progress". Long running requests will not show up until next scan interval. +The scan_interval is how often the integration should fetch data from the servers, if there's no new data from the car then entities won't be updated. + +### Supported setups +This integration will only work for your car if you have Seat Connect functionality. Cars using other third party, semi-official, mobile apps won't work. + +The car privacy settings must be set to "Share my position" for full functionality of this integration. Without this setting, if set to "Use my position", the sensors for position (device tracker), requests remaining and parking time might not work reliably or at all. Set to even stricter privacy setting will limit functionality even further. + +### What is working, all cars +- Automatic discovery of enabled functions (API endpoints). +- Charging plug connected +- Charging plug locked +- Charger connected (external power) +- Battery level +- Electric range +- Start/stop charging +- Start/stop Electric climatisation, window_heater and information +- Charger maximum current (untested) +- Set departure timers (untested) +- Odometer and service info +- Fuel level, range, adblue level +- Lock, windows, trunk, hood, sunroof and door status +- Last trip info +- Position - gps coordinates, if vehicle is moving, time parked +- Start/stop auxiliary climatisation for PHEV cars +- Lock and unlock car +- Parking heater heating/ventilation (for non-electric cars) +- Requests information - latest status, requests remaining until throttled +- Device tracker - entity is set to 'not_home' when car is moving +- Trigger data refresh - this will trigger a wake up call so the car sends new data + +### Under development and BETA functionality (may be buggy) +- Model image url +- Config flow multiple vehicles from same account +- Service calls + +### What is NOT working +- Switches doesn't immediately update "request results" and "request_in_progress". Long running requests will not show up until completed which might take up to 3-5 minutes. +- Config flow convert from yaml config ### Breaking changes -- Enabled API endpoints (functions) are discovered through fetching "operationlist". This has not been tested for all cars and might prove unreliable. +- Changed configuration from yaml to config flow for version >1.1.0. Please remove all yaml configuration and use integration config in HASS UI. - Combustion heater/ventilation is now named parking heater so it's not mixed up with aux heater for PHEV -- Many resources have changed names to avoid confusion in the code, some have changed from sensor to switch and vice versa +- Many resources have changed names to avoid confusion in the code, some have changed from sensor to switch and vice versa. Sensors with trailing "_km" in the name has been renamed to "_distance" for better compability between imperial and non-imperial units. - Major code changes has been made for requests handling. - request_in_progress is now a binary sensor instead of a switch - force_data_refresh is a new switch with the same functionality as "request_in_progress" previously, it will force refresh data from car @@ -48,115 +59,38 @@ The scan_interval is how often the integration should fetch data from the server ## Installation ### Install with HACS (recomended) -If you have HACS (Home Assistant Community Store) installed, just search for Seat Connect and install it direct from HACS. +If you have HACS (Home Assistant Community Store) installed, add this github repo as a custom repository and install. HACS will keep track of updates and you can easly upgrade to the latest version when a new release is available. -If you don't have it installed, check it out here: [HACS](https://community.home-assistant.io/t/custom-component-hacs) +If you don't have HACS installed, check it out here: [HACS](https://community.home-assistant.io/t/custom-component-hacs) ### Manual installation Clone or copy the repository and copy the folder 'homeassistant-seatconnect/custom_component/seatconnect' into '/custom_components' ## Configure -Add a seatconnect configuration block to your `/configuration.yaml`: -```yaml -seatconnect: - username: - password: - spin: - scandinavian_miles: false - scan_interval: - minutes: 1 - name: - wvw1234567812356: 'Kodiaq' -``` -* **username:** (required) the username to your Seat Connect account - -* **password:** (required) the password to your Seat Connect account +Configuration in configuration.yaml is now deprecated and can interfere with setup of the integration. +To configure the integration, go to Configuration in the side panel of Home Assistant and then select Integrations. +Click on the "ADD INTEGRATION" button in the bottom right corner and search/select seatconnect. +Follow the steps and enter the required information. Because of how the data is stored and handled in Home Assistant, there will be one integration per vehicle. +Setup multiple vehicles by adding the integration multiple times. -* **spin:** (optional) required for supporting combustion engine heating start/stop. +### Configuration options +The integration options can be changed after setup by clicking on the "CONFIGURE" text on the integration. +The options available are: -* **scandinavian_miles:** (optional) set to true if you want to change from km to mi on sensors. Conversion between fahrenheit and celcius is taken care of by Home Assistant. (Default: false) +* **Poll frequency** The interval (in minutes) that the servers are polled for updated data. -* **scan_interval:** (optional) specify in minutes how often to fetch status data from Seat Connect. (Default: 5 min, minimum 1 min) +* **S-PIN** The S-PIN for the vehicle. This is optional and is only needed for certain vehicle requests/actions (auxiliary heater, lock etc). -* **name:** (optional) map the vehicle identification number (VIN) to a friendly name of your car. This name is then used for naming all entities. See the configuration example. (by default, the VIN is used). VIN need to be entered lower case +* **Mutable** Select to allow interactions with vehicle, start climatisation etc. +* **Full API debug logging** Enable full debug logging. This will print the full respones from API to homeassistant.log. Only enable for troubleshooting since it will generate a lot of logs. -Additional optional configuration options, only add if needed! -The resources option will limit what entities gets added to home assistant, only the specified resources will be added if they are supported. -If not specified then the integration will add all supported entities: -```yaml - response_debug: False - resources: - # Binary sensors - - charging_cable_connected - - charging_cable_locked - - door_closed_left_front - - door_closed_left_back - - door_closed_right_front - - door_closed_right_back - - doors_locked - - energy_flow - - external_power - - hood_closed - - parking_light - - request_in_progress - - sunroof_closed - - trunk_closed - - trunk_locked - - vehicle_moving - - window_closed_left_front - - window_closed_left_back - - window_closed_right_front - - window_closed_right_back - - windows_closed - # Device tracker - - position - # Locks - - door_locked - - trunk_locked - # Sensors - - adblue_level - - battery_level - - charger_max_ampere - - charging_time_left - - climatisation_target_temperature - - combined_range - - combustion_range - - electric_range - - fuel_level - - last_connected - - last_trip_average_electric_consumption - - last_trip_average_fuel_consumption - - last_trip_average_speed - - last_trip_duration - - last_trip_length - - odometer - - oil_inspection_days - - oil_inspection_distance - - outside_temperature - - parking_time - - pheater_status - - pheater_duration - - request_results - - requests_remaining - - service_inspection_days - - service_inspection_distance - # Switches - - auxiliary_climatisation - - charging - - climatisation_from_battery - - electric_climatisation - - force_data_refresh - - parking_heater_heating - - parking_heater_ventilation - - window_heater -``` +* **Resources to monitor** Select which resources you wish to monitor for the vehicle. -* **response_debug:** (optional) set to true to log raw HTTP data from Seat Connect. This will flood the log, only enable if needed. (Default: false) +* **Distance/unit conversions** Select if you want to convert distance/units. -* **resources:** (optional) use to enable/disable entities. If specified, only the listed entities will be created. If not specified all supported entities will be created. ## Automations @@ -164,7 +98,38 @@ In this example we are sending notifications to an ios device. The Android compa Save these automations in your automations file `/automations.yaml` +### Use input_select to change pre-heater duration +First create a input_number, via editor in configuration.yaml or other included config file, or via GUI Helpers editor. +It is important to set minimum value to 10, maximum to 60 and step to 10. Otherwise the service call might not work. +```yaml +# configuration.yaml +input_number: + pheater_duration: + name: "Pre-heater duration" + initial: 20 + min: 10 + max: 60 + step: 10 + unit_of_measurement: min +``` +Create the automation, in yaml or via GUI editor. +```yaml +# automations.yaml +- alias: Set pre-heater duration + trigger: + - platform: state + entity_id: input_number.pheater_duration + action: + - service: seatconnect.set_pheater_duration + data_template: + vin: + duration: > + {{ trigger.to_state.state }} +``` + ### Get notification when your car is on a new place and show a map with start position and end position +Note: only available for iOS devices since Android companion app does not support this yet. +This might be broken since 1.0.30 when device_tracker changed behaviour. ```yaml - id: notify_seat_position_change description: Notify when position has been changed @@ -172,6 +137,9 @@ Save these automations in your automations file `/automations.yaml` trigger: - platform: state entity_id: device_tracker.arona + condition: template + condition: template + value_template: "{{ trigger.to_state.state != trigger.from_state.state }}" action: - service: notify.ios_my_ios_device data_template: @@ -193,21 +161,58 @@ Save these automations in your automations file `/automations.yaml` shows_traffic: true ``` +### Announce when your car is unlocked but no one is home +```yaml +- id: 'notify_seat_car_is_unlocked' + alias: Seat is at home and unlocked + trigger: + - entity_id: binary_sensor.arona_external_power + platform: state + to: 'on' + for: 00:10:00 + condition: + - condition: state + entity_id: lock.arona_door_locked + state: unlocked + - condition: state + entity_id: device_tracker.arona + state: home + - condition: time + after: '07:00:00' + before: '21:00:00' + action: + # Notification via push message to smartphone + - service: notify.device + data: + message: "The car is unlocked!" + target: + - device/my_device + # Notification via smart speaker (kitchen) + - service: media_player.volume_set + data: + entity_id: media_player.kitchen + volume_level: '0.6' + - service: tts.google_translate_say + data: + entity_id: media_player.kitchen + message: "My Lord, the car is unlocked. Please attend this this issue at your earliest inconvenience!" +``` + ## Enable debug logging For comprehensive debug logging you can add this to your `/configuration.yaml`: ```yaml logger: - default: info - logs: - seatconnect.connection: debug - seatconnect.vehicle: debug - custom_components.seatconnect: debug - custom_components.seatconnect.climate: debug - custom_components.seatconnect.lock: debug - custom_components.seatconnect.device_tracker: debug - custom_components.seatconnect.switch: debug - custom_components.seatconnect.binary_sensor: debug - custom_components.seatconnect.sensor: debug + default: info + logs: + seatconnect.connection: debug + seatconnect.vehicle: debug + custom_components.seatconnect: debug + custom_components.seatconnect.climate: debug + custom_components.seatconnect.lock: debug + custom_components.seatconnect.device_tracker: debug + custom_components.seatconnect.switch: debug + custom_components.seatconnect.binary_sensor: debug + custom_components.seatconnect.sensor: debug ``` * **seatconnect.connection:** Set the debug level for the Connection class of the Seat Connect library. This handles the GET/SET requests towards the API @@ -218,3 +223,6 @@ logger: * **custom_components.seatconnect:** Set debug level for the custom component. The communication between hass and library. * **custom_components.seatconnect.XYZ** Sets debug level for individual entity types in the custom component. + +## Further help or contributions +For questions, further help or contributions you can join the (Skoda Connect) Discord server at https://discord.gg/826X9jEtCh diff --git a/custom_components/seatconnect/__init__.py b/custom_components/seatconnect/__init__.py old mode 100644 new mode 100755 index e2ebc29..86d84cc --- a/custom_components/seatconnect/__init__.py +++ b/custom_components/seatconnect/__init__.py @@ -1,273 +1,556 @@ # -*- coding: utf-8 -*- -import logging -from datetime import timedelta +""" +Seat Connect integration -import homeassistant.helpers.config_validation as cv +Read more at https://github.com/farfar/homeassistant-seatconnect/ +""" +import re +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Union import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, SOURCE_REAUTH, SOURCE_IMPORT from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_RESOURCES, - CONF_SCAN_INTERVAL, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, + CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.util.dt import utcnow +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + from seatconnect import Connection +from seatconnect.vehicle import Vehicle +from seatconnect.exceptions import ( + SeatConfigException, + SeatAuthenticationException, + SeatAccountLockedException, + SeatTokenExpiredException, + SeatException, + SeatEULAException, + SeatThrottledException, + SeatLoginFailedException, + SeatInvalidRequestException, + SeatRequestInProgressException +) -__version__ = "1.0.32" -_LOGGER = logging.getLogger(__name__) +from .const import ( + PLATFORMS, + CONF_MUTABLE, + CONF_SCANDINAVIAN_MILES, + CONF_SPIN, + CONF_VEHICLE, + CONF_UPDATE_INTERVAL, + CONF_INSTRUMENTS, + DATA, + DATA_KEY, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + MIN_UPDATE_INTERVAL, + SIGNAL_STATE_UPDATED, + UNDO_UPDATE_LISTENER, UPDATE_CALLBACK, CONF_DEBUG, DEFAULT_DEBUG, CONF_CONVERT, CONF_NO_CONVERSION, + CONF_IMPERIAL_UNITS, + SERVICE_SET_SCHEDULE, + SERVICE_SET_MAX_CURRENT, + SERVICE_SET_CHARGE_LIMIT, + SERVICE_SET_CLIMATER, + SERVICE_SET_PHEATER_DURATION, +) -DOMAIN = "seatconnect" -DATA_KEY = DOMAIN -CONF_MUTABLE = "mutable" -CONF_SPIN = "spin" -CONF_FULLDEBUG = "response_debug" -CONF_PHEATER_DURATION = "climatisation_duration" -CONF_MILES = "scandinavian_miles" - -SIGNAL_STATE_UPDATED = f"{DOMAIN}.updated" - -MIN_UPDATE_INTERVAL = timedelta(minutes=1) -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) - -COMPONENTS = { - "sensor": "sensor", - "binary_sensor": "binary_sensor", - "lock": "lock", - "device_tracker": "device_tracker", - "switch": "switch", - "climate": "climate", -} - -RESOURCES = [ - "position", - "distance", - "request_in_progress", - "requests_remaining", - "request_results", - "last_connected", - "parking_light", - "adblue_level", - "battery_level", - "fuel_level", - "combustion_range", - "electric_range", - "combined_range", - "service_inspection", - "oil_inspection", - "service_inspection_km", - "oil_inspection_km", - "charging", - "charging_cable_connected", - "charging_cable_locked", - "charging_time_left", - "charge_max_ampere", - "external_power", - "energy_flow", - "outside_temperature", - "climatisation_target_temperature", - "climatisation_without_external_power", - "window_heater", - "electric_climatisation", - "auxiliary_climatisation", - "pheater_heating", - "pheater_ventilation", - "pheater_status", - "pheater_duration", - "door_locked", - "door_closed_left_front", - "door_closed_right_front", - "door_closed_left_back", - "door_closed_right_back", - "trunk_locked", - "trunk_closed", - "hood_closed", - "windows_closed", - "window_closed_left_front", - "window_closed_right_front", - "window_closed_left_back", - "window_closed_right_back", - "sunroof_closed", - "trip_last_average_speed", - "trip_last_average_electric_consumption", - "trip_last_average_fuel_consumption", - "trip_last_duration", - "trip_last_length", -] - -CONFIG_SCHEMA = vol.Schema( +SERVICE_SET_SCHEDULE_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MUTABLE, default=True): cv.boolean, - vol.Optional(CONF_SPIN, default=""): cv.string, - vol.Optional(CONF_FULLDEBUG, default=False): cv.boolean, - vol.Optional(CONF_PHEATER_DURATION, default=20): vol.In( - [10, 20, 30, 40, 50, 60] - ), - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): ( - vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)) - ), - vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( - cv.string - ), - vol.Optional(CONF_RESOURCES): vol.All( - cv.ensure_list, [vol.In(RESOURCES)] - ), - vol.Optional(CONF_MILES, default=False): cv.boolean, - } + vol.Required("device_id"): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Required("id"): vol.In([1,2,3]), + vol.Required("time"): cv.string, + vol.Required("enabled"): cv.boolean, + vol.Required("recurring"): cv.boolean, + vol.Optional("date"): cv.string, + vol.Optional("days"): cv.string, + vol.Optional("temp"): vol.All(vol.Coerce(int), vol.Range(min=16, max=30)), + vol.Optional("climatisation"): cv.boolean, + vol.Optional("charging"): cv.boolean, + vol.Optional("charge_current"): vol.Any( + vol.Range(min=1, max=254), + vol.In(['Maximum', 'maximum', 'Max', 'max', 'Minimum', 'minimum', 'Min', 'min', 'Reduced', 'reduced']) ), - }, - extra=vol.ALLOW_EXTRA, + vol.Optional("charge_target"): vol.In([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), + vol.Optional("off_peak_active"): cv.boolean, + vol.Optional("off_peak_start"): cv.string, + vol.Optional("off_peak_end"): cv.string, + } +) +SERVICE_SET_MAX_CURRENT_SCHEMA = vol.Schema( + { + vol.Required("device_id"): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Required("current"): vol.Any( + vol.Range(min=1, max=255), + vol.In(['Maximum', 'maximum', 'Max', 'max', 'Minimum', 'minimum', 'Min', 'min', 'Reduced', 'reduced']) + ), + } +) +SERVICE_SET_CHARGE_LIMIT_SCHEMA = vol.Schema( + { + vol.Required("device_id"): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Required("limit"): vol.In([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), + } +) +SERVICE_SET_CLIMATER_SCHEMA = vol.Schema( + { + vol.Required("device_id"): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Required("enabled", default=True): cv.boolean, + vol.Optional("temp"): vol.All(vol.Coerce(int), vol.Range(min=16, max=30)), + vol.Optional("battery_power"): cv.boolean, + vol.Optional("aux_heater"): cv.boolean, + vol.Optional("spin"): vol.All(cv.string, vol.Match(r"^[0-9]{4}$")) + } ) -SERVICE_SET_PHEATER_DURATION = "set_pheater_duration" SERVICE_SET_PHEATER_DURATION_SCHEMA = vol.Schema( { - vol.Required("vin"): cv.string, - vol.Required("duration"): vol.In([10,20,30,40,50,60]), + vol.Required("device_id"): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Required("duration"): vol.In([10, 20, 30, 40, 50, 60]), } ) +# Set max parallel updates to 2 simultaneous (1 poll and 1 request waiting) +#PARALLEL_UPDATES = 2 + +_LOGGER = logging.getLogger(__name__) + -async def async_setup(hass, config): - """Setup seat connect component""" - session = async_get_clientsession(hass) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Setup Seat Connect component from a config entry.""" + _LOGGER.debug(f'Init async_setup_entry') + hass.data.setdefault(DOMAIN, {}) - _LOGGER.info(f"Starting Seat Connect, version {__version__}") - _LOGGER.debug("Creating connection to seat connect") - connection = Connection( - session=session, - username=config[DOMAIN].get(CONF_USERNAME), - password=config[DOMAIN].get(CONF_PASSWORD), - fulldebug=config[DOMAIN].get(CONF_FULLDEBUG), - interval=config[DOMAIN].get(CONF_SCAN_INTERVAL), + if entry.options.get(CONF_UPDATE_INTERVAL): + update_interval = timedelta(minutes=entry.options[CONF_UPDATE_INTERVAL]) + else: + update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + + coordinator = SeatCoordinator(hass, entry, update_interval) + + if not await coordinator.async_login(): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry, + ) + return False + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_logout) ) - interval = config[DOMAIN].get(CONF_SCAN_INTERVAL) - data = hass.data[DATA_KEY] = SeatData(config) + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + # Get parent device + try: + identifiers={(DOMAIN, entry.unique_id)} + registry = device_registry.async_get(hass) + device = registry.async_get_device(identifiers) + # Get user configured name for device + name = device.name_by_user if not device.name_by_user is None else None + except: + name = None + + data = SeatData(entry.data, name, coordinator) + instruments = coordinator.data + + conf_instruments = entry.data.get(CONF_INSTRUMENTS, {}).copy() + if entry.options.get(CONF_DEBUG, False) is True: + _LOGGER.debug(f"Configured data: {entry.data}") + _LOGGER.debug(f"Configured options: {entry.options}") + _LOGGER.debug(f"Resources from options are: {entry.options.get(CONF_RESOURCES, [])}") + _LOGGER.debug(f"All instruments (data): {conf_instruments}") + new_instruments = {} def is_enabled(attr): """Return true if the user has enabled the resource.""" - return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) + return attr in entry.data.get(CONF_RESOURCES, [attr]) + + components = set() + + # Check if new instruments + for instrument in ( + instrument + for instrument in instruments + if not instrument.attr in conf_instruments + ): + _LOGGER.info(f"Discovered new instrument {instrument.name}") + new_instruments[instrument.attr] = instrument.name + + # Update config entry with new instruments + if len(new_instruments) > 0: + conf_instruments.update(new_instruments) + # Prepare data to update config entry with + update = { + 'data': { + CONF_INSTRUMENTS: dict(sorted(conf_instruments.items(), key=lambda item: item[1])) + }, + 'options': { + CONF_RESOURCES: entry.options.get( + CONF_RESOURCES, + entry.data.get(CONF_RESOURCES, ['none'])) + } + } - def discover_vehicle(vehicle): - """Load relevant platforms.""" - data.vehicles.add(vehicle.vin) + # Enable new instruments if "activate newly enable entitys" is active + if hasattr(entry, "pref_disable_new_entities"): + if not entry.pref_disable_new_entities: + _LOGGER.debug(f"Enabling new instruments {new_instruments}") + for item in new_instruments: + update['options'][CONF_RESOURCES].append(item) + + _LOGGER.debug(f"Updating config entry data: {update.get('data')}") + _LOGGER.debug(f"Updating config entry options: {update.get('options')}") + hass.config_entries.async_update_entry( + entry, + data={**entry.data, **update['data']}, + options={**entry.options, **update['options']} + ) - dashboard = vehicle.dashboard( - mutable=config[DOMAIN][CONF_MUTABLE], - spin=config[DOMAIN][CONF_SPIN], - miles=config[DOMAIN][CONF_MILES], + for instrument in ( + instrument + for instrument in instruments + if instrument.component in PLATFORMS and is_enabled(instrument.slug_attr) + ): + data.instruments.add(instrument) + components.add(PLATFORMS[instrument.component]) + + for component in components: + coordinator.platforms.append(component) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) ) - for instrument in ( - instrument - for instrument in dashboard.instruments - if instrument.component in COMPONENTS and is_enabled(instrument.slug_attr) - ): - - data.instruments.add(instrument) - hass.async_create_task( - discovery.async_load_platform( - hass, - COMPONENTS[instrument.component], - DOMAIN, - (vehicle.vin, instrument.component, instrument.attr), - config, - ) - ) + hass.data[DOMAIN][entry.entry_id] = { + UPDATE_CALLBACK: update_callback, + DATA: data, + UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener), + } - async def update(now): - """Update status from Seat Connect""" + # Service functions + async def get_car(service_call): + """Get VIN associated with HomeAssistant device ID.""" + # Get device entry + dev_id = service_call.data.get("device_id") + dev_reg = device_registry.async_get(hass) + dev_entry = dev_reg.async_get(dev_id) + + # Get vehicle VIN from device identifiers + seat_identifiers = [ + identifier + for identifier in dev_entry.identifiers + if identifier[0] == DOMAIN + ] + vin_identifier = next(iter(seat_identifiers)) + vin = vin_identifier[1] + + # Get coordinator handling the device entry + conf_entry = next(iter(dev_entry.config_entries)) try: - # Try to login - if not connection.logged_in: - await connection._login() - if not connection.logged_in: - _LOGGER.warning( - "Could not login to Seat Connect, please check your credentials and verify that the service is working" - ) - return False - - # Update vehicle information - if not await connection.update(): - _LOGGER.warning("Could not query update from Seat Connect") - return False + dev_coordinator = hass.data[DOMAIN][conf_entry]['data'].coordinator + except: + raise SeatConfigException('Could not find associated coordinator for given vehicle') - _LOGGER.debug("Updating data from Seat Connect") - for vehicle in connection.vehicles: - if vehicle.vin not in data.vehicles: - _LOGGER.info(f"Adding data for VIN: {vehicle.vin} from Seat Connect") - discover_vehicle(vehicle) + # Return with associated Vehicle class object + return dev_coordinator.connection.vehicle(vin) - async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) - return True - finally: - async_track_point_in_utc_time(hass, update, utcnow() + interval) - - async def set_pheater_duration(call): - """Prepare data and modify parking heater duration.""" + async def set_schedule(service_call=None): + """Set departure schedule.""" try: - _LOGGER.debug("Try to fetch object for VIN: %s" % call.data.get("vin", "")) - vin = call.data.get("vin") - car = connection.vehicle(vin) - _LOGGER.debug(f"Found car: {car.nickname}") - _LOGGER.debug(f"Set climatisation duration to {call.data.get('duration', 0)}") - car.pheater_duration = call.data.get("duration") - async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) - return - except Exception as error: - _LOGGER.warning(f"Couldn't execute, error: {err}") - async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) - raise Exception(f"Service call failed") - - async def cleanup(call): - """Terminate session and clean up.""" - _LOGGER.info(f'Cleaning up') - await connection.terminate() - - _LOGGER.info("Starting seatconnect component") - - # Register services and callbacks + # Prepare data + id = service_call.data.get("id", 0) + temp = None + + # Convert datetime objects to simple strings or check that strings are correctly formatted + try: + time = service_call.data.get("time").strftime("%H:%M") + except: + if re.match('^[0-9]{2}:[0-9]{2}$', service_call.data.get('time', '')): + time = service_call.data.get("time", "08:00") + else: + raise SeatInvalidRequestException(f"Invalid time string: {service_call.data.get('time')}") + if service_call.data.get("off_peak_start", False): + try: + peakstart = service_call.data.get("off_peak_start").strftime("%H:%M") + except: + if re.match('^[0-9]{2}:[0-9]{2}$', service_call.data.get("off_peak_start", "")): + time = service_call.data.get("off_peak_start", "00:00") + else: + raise SeatInvalidRequestException(f"Invalid value for off peak start hours: {service_call.data.get('off_peak_start')}") + if service_call.data.get("off_peak_end", False): + try: + peakend = service_call.data.get("off_peak_end").strftime("%H:%M") + except: + if re.match('^[0-9]{2}:[0-9]{2}$', service_call.data.get("off_peak_end", "")): + time = service_call.data.get("off_peak_end", "00:00") + else: + raise SeatInvalidRequestException(f"Invalid value for off peak end hours: {service_call.data.get('off_peak_end')}") + + # Convert to parseable data + schedule = { + "id": service_call.data.get("id", 1), + "enabled": service_call.data.get("enabled"), + "recurring": service_call.data.get("recurring"), + "date": service_call.data.get("date"), + "time": time, + "days": service_call.data.get("days", "nnnnnnn"), + } + # Set optional values + # Night rate + if service_call.data.get("climatisation", None) is not None: + schedule["nightRateActive"] = service_call.data.get("climatisation") + if service_call.data.get("off_peak_start", None) is not None: + schedule["nightRateTimeStart"] = service_call.data.get("off_peak_start") + if service_call.data.get("off_peak_end", None) is not None: + schedule["nightRateTimeEnd"] = service_call.data.get("off_peak_end") + # Climatisation and charging options + if service_call.data.get("climatisation", None) is not None: + schedule["operationClimatisation"] = service_call.data.get("climatisation") + if service_call.data.get("charging", None) is not None: + schedule["operationCharging"] = service_call.data.get("charging") + if service_call.data.get("charge_target", None) is not None: + schedule["targetChargeLevel"] = service_call.data.get("charge_target") + if service_call.data.get("charge_current", None) is not None: + schedule["chargeMaxCurrent"] = service_call.data.get("charge_current") + # Global optional options + if service_call.data.get("target_temp", None) is not None: + schedule["targetTemp"] = service_call.data.get("target_temp") + + # Find the correct car and execute service call + car = await get_car(service_call) + _LOGGER.info(f'Set departure schedule {id} with data {schedule} for car {car.vin}') + if await car.set_timer_schedule(id, schedule) is True: + _LOGGER.debug(f"Service call 'set_schedule' executed without error") + await coordinator.async_request_refresh() + else: + _LOGGER.warning(f"Failed to execute service call 'set_schedule' with data '{service_call}'") + except (SeatInvalidRequestException) as e: + _LOGGER.warning(f"Service call 'set_schedule' failed {e}") + except Exception as e: + raise + + async def set_charge_limit(service_call=None): + """Set minimum charge limit.""" + try: + car = await get_car(service_call) + + # Get charge limit and execute service call + limit = service_call.data.get("limit", 50) + if await car.set_charge_limit(limit) is True: + _LOGGER.debug(f"Service call 'set_charge_limit' executed without error") + await coordinator.async_request_refresh() + else: + _LOGGER.warning(f"Failed to execute service call 'set_charge_limit' with data '{service_call}'") + except (SeatInvalidRequestException) as e: + _LOGGER.warning(f"Service call 'set_schedule' failed {e}") + except Exception as e: + raise + + async def set_current(service_call=None): + """Set departure schedule.""" + try: + car = await get_car(service_call) + + # Get charge current and execute service call + current = service_call.data.get('current', None) + if await car.set_charger_current(current) is True: + _LOGGER.debug(f"Service call 'set_current' executed without error") + await coordinator.async_request_refresh() + else: + _LOGGER.warning(f"Failed to execute service call 'set_current' with data '{service_call}'") + except (SeatInvalidRequestException) as e: + _LOGGER.warning(f"Service call 'set_schedule' failed {e}") + except Exception as e: + raise + + async def set_pheater_duration(service_call=None): + """Set duration for parking heater.""" + try: + car = await get_car(service_call) + car.pheater_duration = service_call.data.get("duration", car.pheater_duration) + _LOGGER.debug(f"Service call 'set_pheater_duration' executed without error") + await coordinator.async_request_refresh() + except (SeatInvalidRequestException) as e: + _LOGGER.warning(f"Service call 'set_schedule' failed {e}") + except Exception as e: + raise + + async def set_climater(service_call=None): + """Start or stop climatisation with options.""" + try: + car = await get_car(service_call) + + if service_call.data.get('enabled'): + action = 'auxiliary' if service_call.data.get('aux_heater', False) else 'electric' + temp = service_call.data.get('temp', None) + hvpower = service_call.data.get('battery_power', None) + spin = service_call.data.get('spin', None) + else: + action = 'off' + temp = hvpower = spin = None + # Execute service call + if await car.set_climatisation(action, temp, hvpower, spin) is True: + _LOGGER.debug(f"Service call 'set_climater' executed without error") + await coordinator.async_request_refresh() + else: + _LOGGER.warning(f"Failed to execute service call 'set_current' with data '{service_call}'") + except (SeatInvalidRequestException) as e: + _LOGGER.warning(f"Service call 'set_schedule' failed {e}") + except Exception as e: + raise + + # Register services + hass.services.async_register( + DOMAIN, + SERVICE_SET_SCHEDULE, + set_schedule, + schema = SERVICE_SET_SCHEDULE_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_MAX_CURRENT, + set_current, + schema = SERVICE_SET_MAX_CURRENT_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_CHARGE_LIMIT, + set_charge_limit, + schema = SERVICE_SET_CHARGE_LIMIT_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_CLIMATER, + set_climater, + schema = SERVICE_SET_CLIMATER_SCHEMA + ) hass.services.async_register( DOMAIN, SERVICE_SET_PHEATER_DURATION, set_pheater_duration, - schema=SERVICE_SET_PHEATER_DURATION_SCHEMA + schema = SERVICE_SET_PHEATER_DURATION_SCHEMA ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - return await update(utcnow()) + return True + + +def update_callback(hass, coordinator): + _LOGGER.debug("CALLBACK!") + hass.async_create_task( + coordinator.async_request_refresh() + ) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the component from configuration.yaml.""" + hass.data.setdefault(DOMAIN, {}) + + if hass.config_entries.async_entries(DOMAIN): + return True + + if DOMAIN in config: + _LOGGER.info("Found existing Seat Connect configuration.") + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + _LOGGER.debug("Unloading services") + hass.services.async_remove(DOMAIN, SERVICE_SET_SCHEDULE) + hass.services.async_remove(DOMAIN, SERVICE_SET_MAX_CURRENT) + hass.services.async_remove(DOMAIN, SERVICE_SET_CHARGE_LIMIT) + hass.services.async_remove(DOMAIN, SERVICE_SET_CLIMATER) + hass.services.async_remove(DOMAIN, SERVICE_SET_PHEATER_DURATION) + + _LOGGER.debug("Unloading update listener") + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + return await async_unload_coordinator(hass, entry) + + +async def async_unload_coordinator(hass: HomeAssistant, entry: ConfigEntry): + """Unload auth token based entry.""" + _LOGGER.debug("Unloading coordinator") + coordinator = hass.data[DOMAIN][entry.entry_id][DATA].coordinator + + _LOGGER.debug("Log out from Seat Connect") + await coordinator.async_logout() + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + if platform in coordinator.platforms + ] + ) + ) + if unloaded: + _LOGGER.debug("Unloading entry") + del hass.data[DOMAIN][entry.entry_id] + + if not hass.data[DOMAIN]: + _LOGGER.debug("Unloading data") + del hass.data[DOMAIN] + + return unloaded + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +def get_convert_conf(entry: ConfigEntry): + return CONF_SCANDINAVIAN_MILES if entry.options.get( + CONF_SCANDINAVIAN_MILES, + entry.data.get( + CONF_SCANDINAVIAN_MILES, + False + ) + ) else CONF_NO_CONVERSION class SeatData: """Hold component state.""" - def __init__(self, config): + def __init__(self, config, name=None, coordinator=None): """Initialize the component state.""" self.vehicles = set() self.instruments = set() - self.config = config[DOMAIN] - self.names = self.config.get(CONF_NAME) + self.config = config.get(DOMAIN, config) + self.name = name + self.coordinator = coordinator def instrument(self, vin, component, attr): """Return corresponding instrument.""" return next( ( instrument - for instrument in self.instruments + for instrument in ( + self.coordinator.data + if self.coordinator is not None + else self.instruments + ) if instrument.vehicle.vin == vin and instrument.component == component and instrument.attr == attr @@ -277,37 +560,67 @@ def instrument(self, vin, component, attr): def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" - if vehicle.vin and vehicle.vin.lower() in self.names: - return self.names[vehicle.vin.lower()] - elif vehicle.is_nickname_supported: - return vehicle.nickname - elif vehicle.vin: - return vehicle.vin - else: + try: + # Return name if configured by user + if isinstance(self.name, str): + if len(self.name) > 0: + return self.name + except: + pass + + # Default name to nickname if supported, else vin number + try: + if vehicle.is_nickname_supported: + return vehicle.nickname + elif vehicle.vin: + return vehicle.vin + except: + _LOGGER.info(f"Name set to blank") return "" class SeatEntity(Entity): """Base class for all Seat entities.""" - def __init__(self, data, vin, component, attribute): + def __init__(self, data, vin, component, attribute, callback=None): """Initialize the entity.""" + + def update_callbacks(): + if callback is not None: + callback(self.hass, data.coordinator) + self.data = data self.vin = vin self.component = component self.attribute = attribute + self.coordinator = data.coordinator + self.instrument.callback = update_callbacks + self.callback = callback + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + + # Ignore manual update requests if the entity is disabled + if not self.enabled: + return + + await self.coordinator.async_request_refresh() async def async_added_to_hass(self): """Register update dispatcher.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_STATE_UPDATED, self.async_write_ha_state + if self.coordinator is not None: + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + else: + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_STATE_UPDATED, self.async_write_ha_state + ) ) - ) - - async def update_hass(self): - """Send signal to home assistant to update states.""" - async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED) @property def instrument(self): @@ -355,11 +668,18 @@ def assumed_state(self): @property def device_state_attributes(self): """Return device specific state attributes.""" - return dict( + attributes = dict( self.instrument.attributes, model=f"{self.vehicle.model}/{self.vehicle.model_year}", ) + # Return model image as picture attribute for position entity + if "position" in self.attribute: + if self.vehicle.is_model_image_supported: + attributes["entity_picture"] = self.vehicle.model_image + + return attributes + @property def device_info(self): """Return the device_info of the device.""" @@ -371,7 +691,99 @@ def device_info(self): "sw_version": self.vehicle.model_year, } + @property + def available(self): + """Return if sensor is available.""" + if self.data.coordinator is not None: + return self.data.coordinator.last_update_success + return True + @property def unique_id(self) -> str: """Return a unique ID.""" return f"{self.vin}-{self.component}-{self.attribute}" + + +class SeatCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__(self, hass: HomeAssistant, entry, update_interval: timedelta): + self.vin = entry.data[CONF_VEHICLE].upper() + self.entry = entry + self.platforms = [] + self.report_last_updated = None + self.connection = Connection( + session=async_get_clientsession(hass), + username=self.entry.data[CONF_USERNAME], + password=self.entry.data[CONF_PASSWORD], + fulldebug=self.entry.options.get(CONF_DEBUG, self.entry.data.get(CONF_DEBUG, DEFAULT_DEBUG)), + ) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Update data via library.""" + vehicle = await self.update() + + if not vehicle: + raise UpdateFailed("No vehicles found.") + + # Backward compatibility + default_convert_conf = get_convert_conf(self.entry) + + convert_conf = self.entry.options.get( + CONF_CONVERT, + self.entry.data.get( + CONF_CONVERT, + default_convert_conf + ) + ) + + dashboard = vehicle.dashboard( + mutable=self.entry.options.get(CONF_MUTABLE), + spin=self.entry.options.get(CONF_SPIN), + miles=convert_conf == CONF_IMPERIAL_UNITS, + scandinavian_miles=convert_conf == CONF_SCANDINAVIAN_MILES, + ) + + return dashboard.instruments + + async def async_logout(self, event=None): + """Logout from Seat Connect""" + _LOGGER.debug("Shutdown Seat Connect") + try: + await self.connection.terminate() + self.connection = None + except Exception as ex: + _LOGGER.error("Failed to log out and revoke tokens for Seat Connect. Some tokens might still be valid.") + return False + return True + + async def async_login(self): + """Login to Seat Connect""" + # Check if we can login + if await self.connection.doLogin() is False: + _LOGGER.warning( + "Could not login to Seat Connect, please check your credentials and verify that the service is working" + ) + return False + # Get associated vehicles before we continue + await self.connection.get_vehicles() + return True + + async def update(self) -> Union[bool, Vehicle]: + """Update status from Seat Connect""" + + # Update vehicle data + _LOGGER.debug("Updating data from Seat Connect") + try: + # Get Vehicle object matching VIN number + vehicle = self.connection.vehicle(self.vin) + if not await vehicle.update(): + _LOGGER.warning("Could not query update from Seat Connect") + return False + else: + return vehicle + except Exception as error: + _LOGGER.warning(f"An error occured while requesting update from Seat Connect: {error}") + return False diff --git a/custom_components/seatconnect/binary_sensor.py b/custom_components/seatconnect/binary_sensor.py index 4a1447e..46c9416 100644 --- a/custom_components/seatconnect/binary_sensor.py +++ b/custom_components/seatconnect/binary_sensor.py @@ -4,9 +4,9 @@ import logging from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity +from homeassistant.const import CONF_RESOURCES -from . import DATA_KEY, SeatEntity - +from . import UPDATE_CALLBACK, DATA, DATA_KEY, DOMAIN, SeatEntity _LOGGER = logging.getLogger(__name__) @@ -18,13 +18,38 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([SeatBinarySensor(hass.data[DATA_KEY], *discovery_info)]) +async def async_setup_entry(hass, entry, async_add_devices): + data = hass.data[DOMAIN][entry.entry_id][DATA] + coordinator = data.coordinator + if coordinator.data is not None: + if CONF_RESOURCES in entry.options: + resources = entry.options[CONF_RESOURCES] + else: + resources = entry.data[CONF_RESOURCES] + + async_add_devices( + SeatBinarySensor( + data, instrument.vehicle_name, instrument.component, instrument.attr, hass.data[DOMAIN][entry.entry_id][UPDATE_CALLBACK] + ) + for instrument in ( + instrument + for instrument in data.instruments + if instrument.component == "binary_sensor" and instrument.attr in resources + ) + ) + + return True + + class SeatBinarySensor(SeatEntity, BinarySensorEntity): """Representation of a Seat Binary Sensor """ @property def is_on(self): """Return True if the binary sensor is on.""" - _LOGGER.debug("Getting state of %s" % self.instrument.attr) + # Invert state for lock/window/door to get HA to display correctly + if self.instrument.device_class in ['lock', 'door', 'window']: + return not self.instrument.is_on return self.instrument.is_on @property diff --git a/custom_components/seatconnect/climate.py b/custom_components/seatconnect/climate.py index 981cc5e..b344950 100644 --- a/custom_components/seatconnect/climate.py +++ b/custom_components/seatconnect/climate.py @@ -4,9 +4,8 @@ import logging from homeassistant.components.climate import ClimateEntity - -# from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF, SUPPORT_TARGET_TEMPERATURE, @@ -16,11 +15,12 @@ STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_RESOURCES ) -SUPPORT_HVAC = [HVAC_MODE_HEAT, HVAC_MODE_OFF] +SUPPORT_HVAC = [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] -from . import DATA_KEY, SeatEntity +from . import DATA, DATA_KEY, DOMAIN, SeatEntity _LOGGER = logging.getLogger(__name__) @@ -32,9 +32,31 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([SeatClimate(hass.data[DATA_KEY], *discovery_info)]) +async def async_setup_entry(hass, entry, async_add_devices): + data = hass.data[DOMAIN][entry.entry_id][DATA] + coordinator = data.coordinator + if coordinator.data is not None: + if CONF_RESOURCES in entry.options: + resources = entry.options[CONF_RESOURCES] + else: + resources = entry.data[CONF_RESOURCES] + + async_add_devices( + SeatClimate( + data, instrument.vehicle_name, instrument.component, instrument.attr + ) + for instrument in ( + instrument + for instrument in data.instruments + if instrument.component == "climate" and instrument.attr in resources + ) + ) + + return True + + class SeatClimate(SeatEntity, ClimateEntity): - # class SeatClimate(SeatEntity, ClimateDevice): - """Representation of a Seat Carnet Climate.""" + """Representation of a Seat Connect Climate.""" @property def supported_features(self): @@ -46,9 +68,14 @@ def hvac_mode(self): """Return hvac operation ie. heat, cool mode. Need to be one of HVAC_MODE_*. """ - if self.instrument.hvac_mode: - return HVAC_MODE_HEAT - return HVAC_MODE_OFF + if not self.instrument.hvac_mode: + return HVAC_MODE_OFF + + hvac_modes = { + "HEATING": HVAC_MODE_HEAT, + "COOLING": HVAC_MODE_COOL, + } + return hvac_modes.get(self.instrument.hvac_mode, HVAC_MODE_OFF) @property def hvac_modes(self): @@ -72,14 +99,12 @@ def target_temperature(self): async def async_set_temperature(self, **kwargs): """Set new target temperatures.""" - _LOGGER.debug("Setting temperature for: %s", self.instrument.attr) temperature = kwargs.get(ATTR_TEMPERATURE) if temperature: await self.instrument.set_temperature(temperature) async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" - _LOGGER.debug("Setting mode for: %s", self.instrument.attr) if hvac_mode == HVAC_MODE_OFF: await self.instrument.set_hvac_mode(False) elif hvac_mode == HVAC_MODE_HEAT: diff --git a/custom_components/seatconnect/config_flow.py b/custom_components/seatconnect/config_flow.py new file mode 100644 index 0000000..0352c12 --- /dev/null +++ b/custom_components/seatconnect/config_flow.py @@ -0,0 +1,483 @@ +import homeassistant.helpers.config_validation as cv +import logging +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_RESOURCES, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from seatconnect import Connection +from . import get_convert_conf +from .const import ( + CONF_CONVERT, + CONF_SCANDINAVIAN_MILES, + CONF_IMPERIAL_UNITS, + CONF_NO_CONVERSION, + CONF_DEBUG, + CONVERT_DICT, + CONF_MUTABLE, + CONF_UPDATE_INTERVAL, + CONF_SPIN, + CONF_VEHICLE, + CONF_INSTRUMENTS, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + DEFAULT_DEBUG +) + +_LOGGER = logging.getLogger(__name__) + +class SeatConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + task_login = None + task_finish = None + task_get_vehicles = None + entry = None + + def __init__(self): + """Initialize.""" + self._entry = None + self._init_info = {} + self._data = {} + self._options = {} + self._errors = {} + self._connection = None + self._session = None + + async def async_step_user(self, user_input=None): + if user_input is not None: + self.task_login = None + self.task_update = None + self.task_finish = None + self._errors = {} + self._data = { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_INSTRUMENTS: {}, + CONF_VEHICLE: None + } + # Set default options + self._options = { + CONF_CONVERT: CONF_NO_CONVERSION, + CONF_MUTABLE: True, + CONF_UPDATE_INTERVAL: 5, + CONF_DEBUG: False, + CONF_SPIN: None, + CONF_RESOURCES: [] + } + + _LOGGER.debug("Creating connection to Seat Connect") + self._connection = Connection( + session=async_get_clientsession(self.hass), + username=self._data[CONF_USERNAME], + password=self._data[CONF_PASSWORD], + fulldebug=False + ) + + return await self.async_step_login() + + return self.async_show_form( + step_id="user", data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ), errors=self._errors + ) + + # noinspection PyBroadException + async def _async_task_login(self): + try: + result = await self._connection.doLogin() + except Exception as e: + _LOGGER.error(f"Login failed with error: {e}") + self._errors["base"] = "cannot_connect" + + if result is False: + self._errors["base"] = "cannot_connect" + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def _async_task_get_vehicles(self): + try: + result = await self._connection.get_vehicles() + except Exception as e: + _LOGGER.error(f"Fetch vehicles failed with error: {e}") + self._errors["base"] = "cannot_connect" + + if result is False: + self._errors["base"] = "cannot_connect" + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def async_step_vehicle(self, user_input=None): + if user_input is not None: + self._data[CONF_VEHICLE] = user_input[CONF_VEHICLE] + self._options[CONF_SPIN] = user_input[CONF_SPIN] + self._options[CONF_MUTABLE] = user_input[CONF_MUTABLE] + return await self.async_step_monitoring() + + vin_numbers = self._init_info["CONF_VEHICLES"].keys() + return self.async_show_form( + step_id="vehicle", + data_schema=vol.Schema( + { + vol.Required(CONF_VEHICLE, default=next(iter(vin_numbers))): vol.In(vin_numbers), + vol.Optional(CONF_SPIN, default=""): cv.string, + vol.Required(CONF_MUTABLE, default=True): cv.boolean + } + ), errors=self._errors + ) + + async def async_step_monitoring(self, user_input=None): + if user_input is not None: + self._options[CONF_RESOURCES] = user_input[CONF_RESOURCES] + self._options[CONF_CONVERT] = user_input[CONF_CONVERT] + self._options[CONF_UPDATE_INTERVAL] = user_input[CONF_UPDATE_INTERVAL] + self._options[CONF_DEBUG] = user_input[CONF_DEBUG] + + await self.async_set_unique_id(self._data[CONF_VEHICLE]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._data[CONF_VEHICLE], + data=self._data, + options=self._options + ) + + instruments = self._init_info["CONF_VEHICLES"][self._data[CONF_VEHICLE]] + instruments_dict = { + instrument.attr: instrument.name for instrument in instruments + } + self._data[CONF_INSTRUMENTS] = dict(sorted(instruments_dict.items(), key=lambda item: item[1])) + + return self.async_show_form( + step_id="monitoring", + errors=self._errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_RESOURCES, default=list(self._data[CONF_INSTRUMENTS].keys()) + ): cv.multi_select(self._data[CONF_INSTRUMENTS]), + vol.Required( + CONF_CONVERT, default=CONF_NO_CONVERSION + ): vol.In(CONVERT_DICT), + vol.Required( + CONF_UPDATE_INTERVAL, default=1 + ): cv.positive_int, + vol.Required( + CONF_DEBUG, default=False + ): cv.boolean + } + ), + ) + + # Authentication and login + async def async_step_login(self, user_input=None): + if not self.task_login: + self.task_login = self.hass.async_create_task(self._async_task_login()) + + return self.async_show_progress( + step_id="login", + progress_action="task_login", + ) + + # noinspection PyBroadException + try: + await self.task_login + except Exception: + return self.async_abort(reason="Failed to connect to Seat Connect") + + if self._errors: + return self.async_show_progress_done(next_step_id="user") + + #return self.async_show_progress_done(next_step_id="vehicle") + return await self.async_step_get_vehicles() + + async def async_step_get_vehicles(self, user_input=None): + if not self.task_get_vehicles: + self.task_get_vehicles = self.hass.async_create_task(self._async_task_get_vehicles()) + + return self.async_show_progress( + step_id="get_vehicles", + progress_action="task_get_vehicles" + ) + + # noinspection PyBroadException + try: + await self.task_get_vehicles + except Exception: + return self.async_abort(reason="An error occured when trying to fetch vehicles associated with account.") + + if self._errors: + return self.async_show_progress_done(next_step_id="user") + + for vehicle in self._connection.vehicles: + _LOGGER.info(f"Found data for VIN: {vehicle.vin} from Seat Connect") + if len(self._connection.vehicles) == 0: + return self.async_abort(reason="Could not find any vehicles associated with account!") + + self._init_info["CONF_VEHICLES"] = { + vehicle.vin: vehicle.dashboard().instruments + for vehicle in self._connection.vehicles + } + return self.async_show_progress_done(next_step_id="vehicle") + + + async def async_step_reauth(self, entry) -> dict: + """Handle initiation of re-authentication with Seat Connect.""" + self.entry = entry + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input: dict = None) -> dict: + """Handle re-authentication with Seat Connect.""" + errors: dict = {} + + if user_input is not None: + _LOGGER.debug("Creating connection to Seat Connect") + self._connection = Connection( + session=async_get_clientsession(self.hass), + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + fulldebug=self.entry.options.get(CONF_DEBUG, self.entry.data.get(CONF_DEBUG, DEFAULT_DEBUG)), + ) + + # noinspection PyBroadException + try: + await self._connection.doLogin() + + if not await self._connection.validate_login: + _LOGGER.debug("Unable to login to Seat Connect. Need to accept a new EULA/T&C? Try logging in to the portal: https://my.seat/portal/") + errors["base"] = "cannot_connect" + else: + data = self.entry.data.copy() + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **data, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + except Exception as e: + _LOGGER.error("Failed to login due to error: %s", str(e)) + return self.async_abort(reason="Failed to connect to Connect") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self.entry.data[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + # Configuration.yaml import + async def async_step_import(self, yaml): + """Import existing configuration from YAML config.""" + # Set default data and options + self._data = { + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_INSTRUMENTS: {}, + CONF_VEHICLE: None + } + self._options = { + CONF_CONVERT: CONF_NO_CONVERSION, + CONF_MUTABLE: True, + CONF_UPDATE_INTERVAL: 5, + CONF_DEBUG: False, + CONF_SPIN: None, + CONF_RESOURCES: [] + } + self._init_info = {} + + # Check if integration is already configured + if self._async_current_entries(): + _LOGGER.info(f"Integration is already setup, please remove yaml configuration as it is deprecated") + + # Validate and convert yaml config + if all (entry in yaml for entry in ("username", "password")): + self._data[CONF_USERNAME] = yaml["username"] + self._data[CONF_PASSWORD] = yaml["password"] + else: + return False + if "spin" in yaml: + self._options[CONF_SPIN] = yaml["spin"] + if "scandinavian_miles" in yaml: + if yaml["scandinavian_miles"]: + self._options[CONF_CONVERT] = "scandinavian_miles" + if "scan_interval" in yaml: + if "minutes" in yaml["scan_interval"]: + self._options[CONF_UPDATE_INTERVAL] = int(yaml["scan_interval"]["minutes"]) + if "name" in yaml: + vin = next(iter(yaml["name"])) + self._data[CONF_VEHICLE] = vin.upper() + if "response_debug" in yaml: + if yaml["response_debug"]: + self._options[CONF_DEBUG] = True + + # Try to login and fetch vehicles + self._connection = Connection( + session=async_get_clientsession(self.hass), + username=self._data[CONF_USERNAME], + password=self._data[CONF_PASSWORD], + fulldebug=False + ) + try: + await self._connection.doLogin() + await self._connection.get_vehicles() + except: + raise + + if len(self._connection.vehicles) == 0: + return self.async_abort(reason="Seat Connect account didn't return any vehicles") + self._init_info["CONF_VEHICLES"] = { + vehicle.vin: vehicle.dashboard().instruments + for vehicle in self._connection.vehicles + } + + if self._data[CONF_VEHICLE] is None: + self._data[CONF_VEHICLE] = next(iter(self._init_info["CONF_VEHICLES"])) + elif self._data[CONF_VEHICLE] not in self._init_info["CONF_VEHICLES"]: + self._data[CONF_VEHICLE] = next(iter(self._init_info["CONF_VEHICLES"])) + + await self.async_set_unique_id(self._data[CONF_VEHICLE]) + self._abort_if_unique_id_configured() + + instruments = self._init_info["CONF_VEHICLES"][self._data[CONF_VEHICLE]] + self._data[CONF_INSTRUMENTS] = { + instrument.attr: instrument.name for instrument in instruments + } + + if "resources" in yaml: + for resource in yaml["resources"]: + if resource in self._data[CONF_INSTRUMENTS]: + self._options[CONF_RESOURCES].append(resource) + + return self.async_create_entry( + title=self._data[CONF_VEHICLE], + data=self._data, + options=self._options + ) + + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return SeatConnectOptionsFlowHandler(config_entry) + + +class SeatConnectOptionsFlowHandler(config_entries.OptionsFlow): + """Handle SeatConnect options.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize domain options flow.""" + super().__init__() + + self._config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Manage the options.""" + if user_input is not None: + # Remove some options from "data", theese are to be stored in options + data = self._config_entry.data.copy() + if "spin" in data and user_input.get(CONF_SPIN, "") != "": + data.pop("spin", None) + if "resources" in data: + data.pop("resources", None) + self.hass.config_entries.async_update_entry(self._config_entry, data={**data}) + + options = self._config_entry.options.copy() + options[CONF_UPDATE_INTERVAL] = user_input.get(CONF_UPDATE_INTERVAL, 1) + options[CONF_SPIN] = user_input.get(CONF_SPIN, None) + options[CONF_MUTABLE] = user_input.get(CONF_MUTABLE, True) + options[CONF_DEBUG] = user_input.get(CONF_DEBUG, False) + options[CONF_RESOURCES] = user_input.get(CONF_RESOURCES, []) + options[CONF_CONVERT] = user_input.get(CONF_CONVERT, CONF_NO_CONVERSION) + return self.async_create_entry( + title=self._config_entry, + data={ + **options, + }, + ) + + instruments = self._config_entry.data.get(CONF_INSTRUMENTS, {}) + # Backwards compability + convert = self._config_entry.options.get(CONF_CONVERT, self._config_entry.data.get(CONF_CONVERT, None)) + if convert == None: + convert = "no_conversion" + + instruments_dict = dict(sorted( + self._config_entry.data.get( + CONF_INSTRUMENTS, + self._config_entry.options.get(CONF_RESOURCES, {})).items(), + key=lambda item: item[1])) + + self._config_entry.data.get( + CONF_INSTRUMENTS, + self._config_entry.options.get(CONF_RESOURCES, {}) + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_UPDATE_INTERVAL, + default=self._config_entry.options.get(CONF_UPDATE_INTERVAL, + self._config_entry.data.get(CONF_UPDATE_INTERVAL, 5) + ) + ): cv.positive_int, + vol.Optional( + CONF_SPIN, + default=self._config_entry.options.get(CONF_SPIN, + self._config_entry.data.get(CONF_SPIN, "") + ) + ): cv.string, + vol.Optional( + CONF_MUTABLE, + default=self._config_entry.options.get(CONF_MUTABLE, + self._config_entry.data.get(CONF_MUTABLE, False) + ) + ): cv.boolean, + vol.Optional( + CONF_DEBUG, + default=self._config_entry.options.get(CONF_DEBUG, + self._config_entry.data.get(CONF_DEBUG, False) + ) + ): cv.boolean, + vol.Optional( + CONF_RESOURCES, + default=self._config_entry.options.get(CONF_RESOURCES, + self._config_entry.data.get(CONF_RESOURCES, []) + ) + ): cv.multi_select(instruments_dict), + vol.Required( + CONF_CONVERT, + default=convert + ): vol.In(CONVERT_DICT) + } + ), + ) diff --git a/custom_components/seatconnect/const.py b/custom_components/seatconnect/const.py new file mode 100644 index 0000000..793dbd0 --- /dev/null +++ b/custom_components/seatconnect/const.py @@ -0,0 +1,48 @@ +from datetime import timedelta + +DOMAIN = "seatconnect" +DATA_KEY = DOMAIN + +# Configuration definitions +DEFAULT_DEBUG = False +CONF_MUTABLE = "mutable" +CONF_SPIN = "spin" +CONF_SCANDINAVIAN_MILES = "scandinavian_miles" +CONF_IMPERIAL_UNITS = "imperial_units" +CONF_NO_CONVERSION = "no_conversion" +CONF_CONVERT = "convert" +CONF_VEHICLE = "vehicle" +CONF_INSTRUMENTS = "instruments" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_DEBUG = "debug" + +# Service definitions +SERVICE_SET_SCHEDULE = "set_departure_schedule" +SERVICE_SET_MAX_CURRENT = "set_charger_max_current" +SERVICE_SET_CHARGE_LIMIT = "set_charge_limit" +SERVICE_SET_CLIMATER = "set_climater" +SERVICE_SET_PHEATER_DURATION = "set_pheater_duration" + +UPDATE_CALLBACK = "update_callback" +DATA = "data" +UNDO_UPDATE_LISTENER = "undo_update_listener" + +SIGNAL_STATE_UPDATED = f"{DOMAIN}.updated" + +MIN_UPDATE_INTERVAL = timedelta(minutes=1) +DEFAULT_UPDATE_INTERVAL = 5 + +CONVERT_DICT = { + CONF_NO_CONVERSION: "No conversion", + CONF_IMPERIAL_UNITS: "Imperial units", + CONF_SCANDINAVIAN_MILES: "km to mil", +} + +PLATFORMS = { + "sensor": "sensor", + "binary_sensor": "binary_sensor", + "lock": "lock", + "device_tracker": "device_tracker", + "switch": "switch", + "climate": "climate", +} \ No newline at end of file diff --git a/custom_components/seatconnect/device_tracker.py b/custom_components/seatconnect/device_tracker.py index 0d8c4cc..97a584b 100644 --- a/custom_components/seatconnect/device_tracker.py +++ b/custom_components/seatconnect/device_tracker.py @@ -4,16 +4,39 @@ import logging from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect - -# from homeassistant.helpers.dispatcher import (dispatcher_connect, dispatcher_send) from homeassistant.util import slugify +from homeassistant.const import CONF_RESOURCES -from . import DATA_KEY, SIGNAL_STATE_UPDATED +from . import DATA, DATA_KEY, DOMAIN, SIGNAL_STATE_UPDATED, SeatEntity _LOGGER = logging.getLogger(__name__) +async def async_setup_entry(hass, entry, async_add_devices): + data = hass.data[DOMAIN][entry.entry_id][DATA] + coordinator = data.coordinator + if coordinator.data is not None: + if CONF_RESOURCES in entry.options: + resources = entry.options[CONF_RESOURCES] + else: + resources = entry.data[CONF_RESOURCES] + + async_add_devices( + SeatDeviceTracker( + data, instrument.vehicle_name, instrument.component, instrument.attr + ) + for instrument in ( + instrument + for instrument in data.instruments + if instrument.component == "device_tracker" and instrument.attr in resources + ) + ) + + return True + + async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up the Seat tracker.""" if discovery_info is None: @@ -28,24 +51,36 @@ async def see_vehicle(): host_name = data.vehicle_name(instrument.vehicle) dev_id = "{}".format(slugify(host_name)) _LOGGER.debug("Getting location of %s" % host_name) - if instrument.state[0] is None: - _LOGGER.debug("No GPS location data available.") - await async_see( - dev_id=dev_id, - host_name=host_name, - location_name="not_home", - source_type=SOURCE_TYPE_GPS, - icon="mdi:car", - ) - else: - await async_see( - dev_id=dev_id, - host_name=host_name, - source_type=SOURCE_TYPE_GPS, - gps=instrument.state, - icon="mdi:car", - ) + await async_see( + dev_id=dev_id, + host_name=host_name, + source_type=SOURCE_TYPE_GPS, + gps=instrument.state, + icon="mdi:car", + ) async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle) return True + + +class SeatDeviceTracker(SeatEntity, TrackerEntity): + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return self.instrument.state[0] + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return self.instrument.state[1] + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @property + def icon(self): + """Return the icon.""" + return "mdi:car" diff --git a/custom_components/seatconnect/lock.py b/custom_components/seatconnect/lock.py index c0a3da1..8fa96a3 100644 --- a/custom_components/seatconnect/lock.py +++ b/custom_components/seatconnect/lock.py @@ -4,11 +4,9 @@ import logging from homeassistant.components.lock import LockEntity +from homeassistant.const import CONF_RESOURCES -from . import DATA_KEY, SeatEntity - -# from homeassistant.components.lock import LockDevice - +from . import DATA, DATA_KEY, DOMAIN, SeatEntity _LOGGER = logging.getLogger(__name__) @@ -21,22 +19,39 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([SeatLock(hass.data[DATA_KEY], *discovery_info)]) +async def async_setup_entry(hass, entry, async_add_devices): + data = hass.data[DOMAIN][entry.entry_id][DATA] + coordinator = data.coordinator + if coordinator.data is not None: + if CONF_RESOURCES in entry.options: + resources = entry.options[CONF_RESOURCES] + else: + resources = entry.data[CONF_RESOURCES] + + async_add_devices( + SeatLock(data, instrument.vehicle_name, instrument.component, instrument.attr) + for instrument in ( + instrument + for instrument in data.instruments + if instrument.component == "lock" and instrument.attr in resources + ) + ) + + return True + + class SeatLock(SeatEntity, LockEntity): - # class SeatLock(SeatEntity, LockDevice): """Represents a Seat Connect Lock.""" @property def is_locked(self): """Return true if lock is locked.""" - _LOGGER.debug("Getting state of %s" % self.instrument.attr) return self.instrument.is_locked async def async_lock(self, **kwargs): """Lock the car.""" await self.instrument.lock() - await super().update_hass() async def async_unlock(self, **kwargs): """Unlock the car.""" await self.instrument.unlock() - await super().update_hass() diff --git a/custom_components/seatconnect/manifest.json b/custom_components/seatconnect/manifest.json index cb75cdd..8ac6927 100644 --- a/custom_components/seatconnect/manifest.json +++ b/custom_components/seatconnect/manifest.json @@ -1,10 +1,15 @@ { "domain": "seatconnect", "name": "Seat Connect", - "documentation": "https://github.com/farfar/homeassistant-seatconnect", + "documentation": "https://github.com/farfar/homeassistant-seatconnect/blob/main/README.md", "issue_tracker": "https://github.com/farfar/homeassistant-seatconnect/issues", "dependencies": [], - "codeowners": ["@farfar"], - "requirements": ["seatconnect==1.0.31"], - "version": "1.0.32" + "config_flow": true, + "codeowners": ["@Farfar"], + "requirements": [ + "seatconnect>=1.1.0", + "homeassistant>=2021.06.0" + ], + "version": "v1.1.0", + "iot_class": "cloud_polling" } diff --git a/custom_components/seatconnect/sensor.py b/custom_components/seatconnect/sensor.py index 7bc6a4d..1ad0d94 100644 --- a/custom_components/seatconnect/sensor.py +++ b/custom_components/seatconnect/sensor.py @@ -3,9 +3,9 @@ """ import logging -from homeassistant.helpers.icon import icon_for_battery_level - -from . import DATA_KEY, SeatEntity +from . import DATA_KEY, DOMAIN, SeatEntity +from .const import DATA +from homeassistant.const import CONF_RESOURCES _LOGGER = logging.getLogger(__name__) @@ -17,13 +17,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([SeatSensor(hass.data[DATA_KEY], *discovery_info)]) +async def async_setup_entry(hass, entry, async_add_devices): + data = hass.data[DOMAIN][entry.entry_id][DATA] + coordinator = data.coordinator + if coordinator.data is not None: + if CONF_RESOURCES in entry.options: + resources = entry.options[CONF_RESOURCES] + else: + resources = entry.data[CONF_RESOURCES] + + async_add_devices( + SeatSensor( + data, instrument.vehicle_name, instrument.component, instrument.attr + ) + for instrument in ( + instrument + for instrument in data.instruments + if instrument.component == "sensor" and instrument.attr in resources + ) + ) + + return True + + class SeatSensor(SeatEntity): - """Representation of a Seat Carnet Sensor.""" + """Representation of a Seat Sensor.""" @property def state(self): """Return the state of the sensor.""" - _LOGGER.debug("Getting state of %s" % self.instrument.attr) return self.instrument.state @property diff --git a/custom_components/seatconnect/services.yaml b/custom_components/seatconnect/services.yaml index 1b7113c..f679452 100644 --- a/custom_components/seatconnect/services.yaml +++ b/custom_components/seatconnect/services.yaml @@ -1,9 +1,265 @@ +# Describes the format for available Seat Connect service calls + +set_charge_limit: + name: Set charge limit + description: > + Set the limit that the charger will charge directly to when + a departure timer is active. + fields: + device_id: + name: Vehicle + description: The vehicle to set charge limit for + required: true + selector: + device: + integration: seatconnect + limit: + name: Limit + description: The charging upper limit + advanced: true + example: 50 + selector: + number: + min: 0 + max: 100 + step: 10 + unit_of_measurement: percent +set_charger_max_current: + name: Set charger max current + description: > + Set the maximum current used for charger. Overrides the current setting in departure timers. + fields: + device_id: + name: Vehicle + description: The vehicle to set maximum current for + required: true + selector: + device: + integration: seatconnect + current: + name: Current + description: > + Maximum current. String (Maximum or Reduced/Minimum) or int 1-255 (1-32 = Amps, 252 = Reduced, 254 = Maximum). + advanced: true + example: 16 + selector: + number: + min: 1 + max: 254 + unit_of_measurement: Ampere set_pheater_duration: - description: "Set duration for parking heater/ventilation" + name: Set parking heater runtime + description: > + Set the runtime of the parking heater. fields: - vin: - description: "VIN number for vehicle to control. [VIN-number] str (required)" - example: "ABCDE9FG0H1234567" + device_id: + name: Vehicle + description: The vehicle to set parking heater duration for + required: true + selector: + device: + integration: seatconnect duration: - description: "[10,20,30,40,50,60] Parking heater duration for heating/ventilation" - example: '20' + name: Runtime + description: Runtime for heating or ventilation of the parking heater. + advanced: true + example: 20 + selector: + number: + min: 10 + max: 60 + step: 10 + unit_of_measurement: min +set_climater: + name: Set climatisation + description: Start/stop climatisation with optional parameters + fields: + device_id: + name: Vehicle + description: The vehicle to set climatisation settings for + required: true + selector: + device: + integration: seatconnect + enabled: + name: Activate + description: Start or stop the climatisation + advanced: true + example: true + default: true + selector: + boolean: + temp: + name: Target temperature + description: The target temperature for climatisation (unselect to use vehicles stored setting) + advanced: true + example: 20 + selector: + number: + min: 16 + max: 30 + step: 1 + unit_of_measurement: °C + battery_power: + name: Battery power + description: > + Allow the use of battery power to run electric climatisation (unselect to use vehicles stored setting) + advanced: true + example: true + default: true + selector: + boolean: + aux_heater: + name: Auxiliary heater + description: > + Use the auxiliary heater for climatisation (disable to use electric), requires S-PIN + advanced: true + example: false + default: false + selector: + boolean: + spin: + name: S-PIN + description: > + The S-PIN for the vehicle + advanced: true + example: 1234 + selector: + text: +set_departure_schedule: + name: Set departure schedule + description: > + Set the departure for one of the departure schedules. + fields: + device_id: + name: Vehicle + description: "[Required] The vehicle to set departure schedule for." + required: true + selector: + device: + integration: seatconnect + id: + name: ID + description: "[Required] Which departure schedule to change." + required: true + advanced: true + example: 1 + default: 1 + selector: + number: + min: 1 + max: 3 + mode: box + time: + name: Time + description: "[Required] The time for departure, 24h HH:MM." + required: true + advanced: true + example: "17:00" + default: "08:00" + selector: + text: + enabled: + name: Activated + description: "[Required] If the departure schedule should be activated." + required: true + advanced: true + example: true + default: true + selector: + boolean: + recurring: + name: Recurring schedule + description: "[Required] Wether the schedule should be recurring or one off." + required: true + advanced: true + example: false + default: false + selector: + boolean: + date: + name: Date + description: "The date for departure (required for single schedule, not recurring)." + advanced: true + example: "2021-06-31" + selector: + text: + days: + name: Days + description: "Weekday mask for recurring schedule, mon-sun - (required for recurring schedule, not single)." + advanced: true + example: "yyynnnn" + default: "yyyyynn" + selector: + text: + temp: + name: Target temperature + description: "[Optional] Target temperature for climatisation. Global setting and affects all climatisation actions and schedules." + advanced: true + example: 20 + default: 20 + selector: + number: + min: 16 + max: 30 + mode: slider + climatisation: + name: Climatisation + description: "[Optional] Wether or not to enable climatisation for this departure." + advanced: true + example: true + default: true + selector: + boolean: + charging: + name: Charging + description: "[Optional] Wether or not to enable charging for this departure." + advanced: true + example: true + default: true + selector: + boolean: + charge_current: + name: Current + description: "[Optional] Maximum charging current for this departure. (1-254 or maximum/reduced)" + advanced: true + example: "Maximum" + default: "Maximum" + selector: + text: + charge_target: + name: Charge Target + description: "[Optional] The target charge level for departure." + advanced: true + example: 100 + default: 100 + selector: + number: + min: 0 + max: 100 + step: 10 + unit_of_measurement: percent + off_peak_active: + name: Off-peak active + description: "[Optional] Enable off-peak hours" + advanced: true + example: false + default: false + selector: + boolean: + off_peak_start: + name: Off-peak Start + description: "[Optional] The time when off-peak hours for electric price start, 24h HH:MM." + advanced: true + example: "00:00" + default: "00:00" + selector: + text: + off_peak_end: + name: Off-peak End + description: "[Optional] The time when off-peak hours for electric price end, 24h HH:MM." + advanced: true + example: "06:00" + default: "06:00" + selector: + text: \ No newline at end of file diff --git a/custom_components/seatconnect/strings.json b/custom_components/seatconnect/strings.json new file mode 100644 index 0000000..915e81d --- /dev/null +++ b/custom_components/seatconnect/strings.json @@ -0,0 +1,70 @@ +{ + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Seat Connect Configuration", + "description": "Fill in your Seat Connect account information.", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "vehicle": { + "title": "Vehicle Settings", + "description": "The following vehicle(s) was found. Please select the vehicle you wish to monitor and it's settings.\n\nThe S-PIN is only required for some specific operations such as lock/unlock and operations that activates the combustion engine heating.\nYou can leave it blank.", + "data": { + "vehicle": "VIN Number", + "spin": "S-PIN", + "mutable": "Allow interactions with car (actions). Uncheck to make the car 'read only'." + } + }, + "monitoring": { + "title": "Monitoring Settings", + "description": "Specify additional monitoring settings.", + "data": { + "resources": "Resources to monitor.", + "convert": "Select distance/unit conversions.", + "update_interval": "Poll frequency (minutes).", + "debug": "Full API debug logging (requires debug logging enabled in configuration.yaml)" + } + }, + "reauth_confirm": { + "description": "Re-authenticate with your Seat Connect account.\nMake sure to accept any new EULA in the Seat Connect portal (https://my.seat/portal/) before proceeding. ", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "Could not login to Seat Connect, please check your credentials and verify that the service is working", + "cannot_update": "Could not query update from Seat Connect", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "task_login": "Logging in to Seat Connect", + "task_update": "Fetching vehicles" + } + }, + "options": { + "step": { + "user": { + "title": "Options for Seat Connect", + "description": "Configure update interval", + "data": { + "update_interval": "Poll frequency (minutes)", + "spin": "S-PIN", + "mutable": "Allow interactions with car (actions). Uncheck to make the car 'read only'.", + "convert": "Select distance/unit conversions.", + "resources": "Resources to monitor.", + "debug": "Full API debug logging (requires debug logging enabled in configuration.yaml)" + } + } + } + } +} diff --git a/custom_components/seatconnect/switch.py b/custom_components/seatconnect/switch.py index ece49f8..ac24242 100644 --- a/custom_components/seatconnect/switch.py +++ b/custom_components/seatconnect/switch.py @@ -2,10 +2,14 @@ Support for Seat Connect Platform """ import logging +from typing import Any, Dict, Optional +import voluptuous as vol from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.const import CONF_RESOURCES -from . import DATA_KEY, SeatEntity +from . import DATA, DATA_KEY, DOMAIN, SeatEntity, UPDATE_CALLBACK _LOGGER = logging.getLogger(__name__) @@ -17,27 +21,51 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([SeatSwitch(hass.data[DATA_KEY], *discovery_info)]) +async def async_setup_entry(hass, entry, async_add_devices): + data = hass.data[DOMAIN][entry.entry_id][DATA] + coordinator = data.coordinator + if coordinator.data is not None: + if CONF_RESOURCES in entry.options: + resources = entry.options[CONF_RESOURCES] + else: + resources = entry.data[CONF_RESOURCES] + + async_add_devices( + SeatSwitch( + data, instrument.vehicle_name, instrument.component, instrument.attr, hass.data[DOMAIN][entry.entry_id][UPDATE_CALLBACK] + ) + for instrument in ( + instrument + for instrument in data.instruments + if instrument.component == "switch" and instrument.attr in resources + ) + ) + + return True + + class SeatSwitch(SeatEntity, ToggleEntity): """Representation of a Seat Connect Switch.""" @property def is_on(self): """Return true if switch is on.""" - _LOGGER.debug("Getting state of %s" % self.instrument.attr) return self.instrument.state async def async_turn_on(self, **kwargs): """Turn the switch on.""" - _LOGGER.debug("Turning ON %s." % self.instrument.attr) await self.instrument.turn_on() - await super().update_hass() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the switch off.""" - _LOGGER.debug("Turning OFF %s." % self.instrument.attr) await self.instrument.turn_off() - await super().update_hass() + self.async_write_ha_state() @property def assumed_state(self): return self.instrument.assumed_state + + @property + def state_attributes(self) -> Optional[Dict[str, Any]]: + return self.instrument.attributes diff --git a/custom_components/seatconnect/translations/en.json b/custom_components/seatconnect/translations/en.json index 910fb3a..6529c91 100644 --- a/custom_components/seatconnect/translations/en.json +++ b/custom_components/seatconnect/translations/en.json @@ -1,15 +1,70 @@ { - "title": "Seat Connect", - "config": { - "step": { - "user": { - "title": "Seat Connect Configuration", - "description": "Fill in Seat Connect information", - "data": { - "username": "Username", - "password": "Password" - } - } + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Seat Connect Configuration", + "description": "Fill in Seat Connect account information", + "data": { + "username": "Email", + "password": "Password" } + }, + "vehicle": { + "title": "Vehicle Settings", + "description": "The following vehicle(s) was found. Please select the vehicle you wish to monitor and it's settings.\n\nThe S-PIN is only required for some specific operations such as lock/unlock and operations that activates the combustion engine heating.\nYou can leave it blank.", + "data": { + "vehicle": "VIN Number", + "spin": "S-PIN", + "mutable": "Allow interactions with car (actions). Uncheck to make the car 'read only'." + } + }, + "monitoring": { + "title": "Monitoring Settings", + "description": "Specify additional monitoring settings.", + "data": { + "resources": "Resources to monitor.", + "convert": "Select distance/unit conversions.", + "update_interval": "Poll frequency (minutes).", + "debug": "Full API debug logging (requires debug logging enabled in configuration.yaml)" + } + }, + "reauth_confirm": { + "description": "Re-authenticate with your Seat Connect account.\nMake sure to accept any new EULA in the Seat Connect portal (https://my.seat/portal/) before proceeding. ", + "data": { + "username": "Email", + "password": "Password" + } + } + }, + "abort": { + "already_configured": "Car with this VIN is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Could not login to Seat Connect, please check your credentials and verify that the service is working", + "cannot_update": "Could not query update from Seat Connect", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "task_login": "Logging in to Seat Connect", + "task_update": "Fetching vehicles" + } + }, + "options": { + "step": { + "user": { + "title": "Options for Seat Connect", + "description": "Configure settings", + "data": { + "update_interval": "Poll frequency (minutes)", + "spin": "S-PIN", + "mutable": "Allow interactions with car (actions). Uncheck to make the car 'read only'.", + "convert": "Select distance/unit conversions.", + "resources": "Resources to monitor.", + "debug": "Full API debug logging (requires debug logging enabled in configuration.yaml)" + } + } } + } } diff --git a/custom_components/seatconnect/translations/it.json b/custom_components/seatconnect/translations/it.json new file mode 100644 index 0000000..67fa8b1 --- /dev/null +++ b/custom_components/seatconnect/translations/it.json @@ -0,0 +1,70 @@ +{ + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Configurazione Seat Connect", + "description": "Inserisci le informazioni del tuo account Seat Connect", + "data": { + "username": "Email", + "password": "Password" + } + }, + "vehicle": { + "title": "Impostazione Veicolo", + "description": "TYrovati i seguenti veicoli. Perfavore scegli il veicolo che vuoi monitorare e la sua configurazione.\n\nL'S-PIN è richiesto solo per operazioni specifiche come l'Apertura/Chiusura e l'attivazione del riscaldamento\nPuoi lasciare il campo vuoto.", + "data": { + "vehicle": "Numero VIN", + "spin": "S-PIN", + "mutable": "Permetti interazione con l'auto(azioni). Deseleziona se vuoi sono monitorare." + } + }, + "monitoring": { + "title": "Impostazioni monitoraggio", + "description": "Specifica impostazioni aggiuntive per il monitoraggio.", + "data": { + "resources": "Risorse da monitorare.", + "convert": "Scegli la conversione di distanza/unità.", + "update_interval": "Frequenza di aggiornamento (minuti).", + "debug": "API debug logging completo (richiede che il debug logging sia abilitato in configuration.yaml)" + } + }, + "reauth_confirm": { + "description": "Ri-autenticati con il tuo account Seat Connect.\nAccertati di aver accettato le nuove condizione (EULA) nel portale Seat Connect (https://my.seat/portal/) prima di procedere. ", + "data": { + "username": "Email", + "password": "Password" + } + } + }, + "abort": { + "already_configured": "L'auto con questo VIN è già configurata", + "reauth_successful": "Re-autenticazione riuscita" + }, + "error": { + "cannot_connect": "Non riesco ad accedere a Seat Connect, perfavore controlla le tue credenziali e verifica che il servizio funzioni", + "cannot_update": "Richiesta aggiornamento a Seat Connect non riuscita", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "task_login": "Accessp a Seat Connect", + "task_update": "Recupero veicoli" + } + }, + "options": { + "step": { + "user": { + "title": "Opzioni per Seat Connect", + "description": "Configura impostazioni", + "data": { + "update_interval": "Frequenza di aggiornamento (minuti)", + "spin": "S-PIN", + "mutable": "Permetti interazione con l'auto(azioni). Deseleziona se vuoi sono monitorare..", + "convert": "Scegli la conversione di distanza/unità.", + "resources": "Risorse da monitorare.", + "debug": "API debug logging completo (richiede che il debug logging sia abilitato in configuration.yaml)" + } + } + } + } +} diff --git a/custom_components/seatconnect/translations/nb.json b/custom_components/seatconnect/translations/nb.json new file mode 100644 index 0000000..a692860 --- /dev/null +++ b/custom_components/seatconnect/translations/nb.json @@ -0,0 +1,15 @@ +{ + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Seat Connect-konfigurasjon", + "description": "Fyll ut Seat Connect-informasjon", + "data": { + "username": "Brukernavn", + "password": "Passord" + } + } + } + } +} diff --git a/custom_components/seatconnect/translations/nn.json b/custom_components/seatconnect/translations/nn.json new file mode 100644 index 0000000..9edaba8 --- /dev/null +++ b/custom_components/seatconnect/translations/nn.json @@ -0,0 +1,15 @@ +{ + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Seat Connect-konfigurasjon", + "description": "Fyll ut Seat Connect-informasjon", + "data": { + "username": "Brukarnamn", + "password": "Passord" + } + } + } + } +} diff --git a/custom_components/seatconnect/translations/pl.json b/custom_components/seatconnect/translations/pl.json new file mode 100644 index 0000000..3c1ccd0 --- /dev/null +++ b/custom_components/seatconnect/translations/pl.json @@ -0,0 +1,72 @@ +{ + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Konfiguracja Seat Connect", + "description": "Wypełnij informacje dla Seat Connect\n\nS-PIN jest wymagany w przypadku niektórych opcji, takich jak blokowanie/odblokowywanie auta i włączanie/wyłączanie silnika", + "data": { + "username": "Email", + "password": "Hasło" + } + }, + "vehicle": { + "title": "Samochód", + "description": "Znaleziono następujące pojazdy. Wybierz pojazd, który chcesz monitorować i jego ustawienia.\n\nKod S-PIN jest wymagany tylko do niektórych określonych operacji, takich jak blokowanie/odblokowywanie i operacje, które aktywują ogrzewanie silnika spalinowego.\nMożesz pozostawić to pole puste.", + "data": { + "vehicle": "Numer VIN", + "spin": "S-PIN", + "mutable": "Usuń zaznaczenie, aby samochód był „tylko do odczytu”. Jeśli je zostawisz będziesz mógł wejść w interakcję z samochodem" + } + }, + "monitoring": { + "title": "Ustawienia monitorowania", + "description": "Określ dodatkowe ustawienia monitorowania.", + "data": { + "resources": "Zasoby do dodania", + "convert": "Wybierz, jeśli chcesz dokonać konwersji jednostek odległości", + "update_interval": "Częstotliwość sondowania (minuty).", + "debug": "Pełne rejestrowanie debugowania interfejsu API (wymaga włączenia rejestrowania debugowania w pliku configuration.yaml)" + } + }, + "reauth_confirm": { + "data": { + "description": "Ponownie uwierzytelnij się do swojego konta Seat Connect. Przed kontynuowaniem należy zaakceptować każdą nową umowę EULA w portalu Seat Connect (https://my.seat/portal/).", + "username": "Email", + "password": "Hasło" + } + } + }, + "abort": { + "already_configured": "Samochód z tym VIN jest już skonfigurowany", + "reauth_successful": "Ponowne uwierzytelnienie powiodło się" + }, + "error": { + "cannot_connect": "Nie można zalogować się do Seat Connect, sprawdź wpisane dane logowania i sprawdź, czy usługa działa", + "cannot_update": "Nie można zaktualizować danych Seat Connect", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "task_login": "Logowanie do Seat Connect", + "task_update": "Pobieranie listy pojazdów" + } + }, + "options": { + "step": { + "user": { + "title": "Opcje Seat Connect", + "description": "Skonfiguruj opcje monitorowania pojazdu Seat Connect.", + "data": { + "update_interval": "Częstotliwość aktualizacji czujników (minuty)", + "spin": "S-PIN", + "mutable": "Usuń zaznaczenie, aby samochód był „tylko do odczytu”. Jeśli je zostawisz będziesz mógł wejść w interakcję z samochodem", + "convert": "Wybierz, jeśli chcesz dokonać konwersji jednostek odległości", + "resources": "Zasoby do dodania", + "debug": "Włącz pełne dzienniki debugowania z interfejsu API (wymaga włączenia rejestrowania debugowania w konfiguracji)" + } + } + } + } +} + + diff --git a/custom_components/seatconnect/translations/sv.json b/custom_components/seatconnect/translations/sv.json new file mode 100644 index 0000000..20c759c --- /dev/null +++ b/custom_components/seatconnect/translations/sv.json @@ -0,0 +1,70 @@ +{ + "title": "Seat Connect", + "config": { + "step": { + "user": { + "title": "Seat Connect Konfiguration", + "description": "Fyll i uppgifterna för ditt Seat Connect konto.", + "data": { + "username": "Användarnamn", + "password": "Lösenord" + } + }, + "vehicle": { + "title": "Fordonsinställningar", + "description": "Följande fordon hittades. Välj det fordon som ska övervakas och alternativ.\n\nS-PIN krävs endast för specifika operationer såsom lås/lås upp m.fl.\nDu kan lämna S-PIN blankt.", + "data": { + "vehicle": "Chassinummer", + "spin": "S-PIN", + "mutable": "Tillåt interaktioner med bilen (åtgärder). Avmarkera för att göra bilen 'skrivskyddad'." + } + }, + "monitoring": { + "title": "Övervakningsinställningar", + "description": "Ange övervaknings alternativ.", + "data": { + "resources": "Resurser att övervaka.", + "convert": "Ange enhetsomvandling.", + "update_interval": "Uppdateringsfrekvens (minuter).", + "debug": "Full API debug loggning (kräver debug loggning aktiverat i configuration.yaml)" + } + }, + "reauth_confirm": { + "description": "Återautentisera ditt Seat Connect konto.\nSäkerställ att du accepterat eventuell ny EULA i Seat Connect portalen (https://my.seat/portal/) innan du fortsätter. ", + "data": { + "email": "Användarnamn", + "password": "Lösenord" + } + } + }, + "abort": { + "already_configured": "En bil med detta chassinummer är redan konfigurerad", + "reauth_successful": "Autentisering lyckades" + }, + "error": { + "cannot_connect": "Kunde inte logga in mot Seat Connect, verifiera att du angett rätt användaruppgifter och att tjänsten är tillgänglig.", + "cannot_update": "Kunde inte hämta data från Seat Connect", + "unknown": "Okänt fel" + }, + "progress": { + "task_login": "Loggar in mot Seat Connect", + "task_update": "Hämtar fordon" + } + }, + "options": { + "step": { + "user": { + "title": "Alternativ för Seat Connect", + "description": "Alternativ för hantering av Seat Connect.", + "data": { + "update_interval": "Uppdateringsfrekvens (minuter)", + "spin": "S-PIN", + "mutable": "Tillåt interaktioner med bilen (åtgärder). Avmarkera för att göra bilen 'skrivskyddad'.", + "convert": "Ange enhetsomvandling.", + "resources": "Resurser att övervaka.", + "debug": "Full API debug loggning (kräver debug loggning aktiverat i configuration.yaml)" + } + } + } + } + } diff --git a/hacs.json b/hacs.json index 448fc43..4d081e1 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Seat Connect", "iot_class": "Cloud Polling", - "homeassistant": "0.110.0", + "homeassistant": "2021.6.0", "hide_default_branch": true, "zip_release": false, "filename": "seatconnect.zip" diff --git a/requirements.txt b/requirements.txt index 8b95f9b..afb17f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -seatconnect==1.0.31 +seatconnect>=1.1.0