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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,5 @@ dmypy.json
.pyre/
.DS_Store
src/carconnectivity_connectors/skoda/_version.py

skoda_config.json
72 changes: 72 additions & 0 deletions src/carconnectivity_connectors/skoda/climatization.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,78 @@ def __init__(self, vehicle: GenericVehicle | None = None, origin: Optional[Clima
super().__init__(vehicle=vehicle)
self.settings: Climatization.Settings = SkodaClimatization.Settings(parent=self)
self.errors: Dict[str, Error] = {}
# Add custom Skoda-specific attributes
from carconnectivity.attributes import GenericAttribute
from carconnectivity.objects import GenericObject
self.running_requests: GenericAttribute = GenericAttribute(name='running_requests', parent=self)

# Add active ventilation timers support
if hasattr(origin, "active_ventilation_timers"):
self.active_ventilation_timers: SkodaClimatization.ActiveVentilationTimers = origin.active_ventilation_timers
self.active_ventilation_timers.parent = self
else:
self.active_ventilation_timers: SkodaClimatization.ActiveVentilationTimers = SkodaClimatization.ActiveVentilationTimers(parent=self)



class ActiveVentilationTimers(GenericObject):
"""
This class represents the active ventilation timers for Skoda car climatization.
"""

def __init__(self, parent: Optional[GenericObject] = None) -> None:
super().__init__(object_id="active_ventilation_timers", parent=parent)

# Raw timer data from API - will be updated with actual timer list
from carconnectivity.attributes import GenericAttribute
self.raw_data = GenericAttribute("raw_data", self, value=None, tags={"connector_custom"})

# Individual timer objects will be created dynamically based on API response
# Example: self.timer_1, self.timer_2, etc.

def update_timers(self, timers_data: list, captured_at) -> None:
"""Update timer data from API response"""
# Store raw data
self.raw_data._set_value(value=timers_data, measured=captured_at)

# Clear existing timer attributes properly by removing parent relationship first
existing_timers = [attr for attr in dir(self) if attr.startswith("timer_") and not attr.startswith("_")]
for timer_attr in existing_timers:
try:
timer_obj = getattr(self, timer_attr)
# Remove parent relationship first - this removes it from parent's children list
timer_obj.parent = None
# Now delete the attribute
delattr(self, timer_attr)
except (AttributeError, TypeError):
pass

# Create individual timer objects
for timer in timers_data:
if "id" in timer:
timer_id = timer["id"]
timer_attr_name = f"timer_{timer_id}"

# Create a GenericObject for this timer
timer_obj = GenericObject(object_id=timer_attr_name, parent=self)

# Add timer properties
from carconnectivity.attributes import BooleanAttribute, StringAttribute, GenericAttribute
timer_obj.timer_enabled = BooleanAttribute(
"timer_enabled", timer_obj, value=timer.get("enabled", False), tags={"connector_custom"}
)
timer_obj.time = StringAttribute(
"time", timer_obj, value=timer.get("time", "00:00"), tags={"connector_custom"}
)
timer_obj.type = StringAttribute(
"type", timer_obj, value=timer.get("type", "ONE_OFF"), tags={"connector_custom"}
)
timer_obj.selected_days = GenericAttribute(
"selected_days", timer_obj, value=timer.get("selectedDays", []), tags={"connector_custom"}
)

# Set the timer object as an attribute of this Timers object
setattr(self, timer_attr_name, timer_obj)

class Settings(Climatization.Settings):
"""
Expand Down
178 changes: 173 additions & 5 deletions src/carconnectivity_connectors/skoda/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,12 @@ def update_vehicles(self) -> None:
vehicle_to_update = self.fetch_position(vehicle_to_update)
if vehicle_to_update.capabilities.has_capability('CHARGING', check_status_ok=True) and isinstance(vehicle_to_update, SkodaElectricVehicle):
vehicle_to_update = self.fetch_charging(vehicle_to_update)
if vehicle_to_update.capabilities.has_capability('AIR_CONDITIONING', check_status_ok=True):
if vehicle_to_update.capabilities.has_capability('AIR_CONDITIONING', check_status_ok=True) or \
vehicle_to_update.capabilities.has_capability('ACTIVE_VENTILATION', check_status_ok=True):
vehicle_to_update = self.fetch_air_conditioning(vehicle_to_update)
# Check for auxiliary heating - using same capability as air conditioning since they are related
if vehicle_to_update.capabilities.has_capability('ACTIVE_VENTILATION', check_status_ok=True):
vehicle_to_update = self.fetch_auxiliary_heating(vehicle_to_update)
if vehicle_to_update.capabilities.has_capability('VEHICLE_HEALTH_INSPECTION', check_status_ok=True):
vehicle_to_update = self.fetch_maintenance(vehicle_to_update)
vehicle_to_update = self.decide_state(vehicle_to_update)
Expand Down Expand Up @@ -787,10 +791,10 @@ def fetch_air_conditioning(self, vehicle: SkodaVehicle, no_cache: bool = False)
Fetches the air conditioning data for a given Skoda vehicle and updates the vehicle object with the retrieved data.

Args:
vehicle (SkodaVehicle): The vehicle object for which to fetch air conditioning data.
vehicle (SkodaVehicle): The vehicle object for which to fetch air conditioning/ventilation data.

Returns:
SkodaVehicle: The updated vehicle object with the fetched air conditioning data.
SkodaVehicle: The updated vehicle object with the fetched air conditioning/ventilation data.

Raises:
APIError: If the VIN is missing or if the carCapturedTimestamp is missing in the response data.
Expand All @@ -804,8 +808,17 @@ def fetch_air_conditioning(self, vehicle: SkodaVehicle, no_cache: bool = False)
vin = vehicle.vin.value
if vin is None:
raise APIError('VIN is missing')

# Log which capability triggered the air conditioning fetch
active_capabilities = []
for cap in ['AIR_CONDITIONING', 'ACTIVE_VENTILATION']: # Only check same capabilities as line 390
if hasattr(vehicle, 'capabilities') and vehicle.capabilities and vehicle.capabilities.has_capability(cap):
active_capabilities.append(cap)
if active_capabilities:
LOG.debug("Fetching air conditioning data for %s triggered by capabilities: %s", vin, ', '.join(active_capabilities))

if vehicle.position is None:
raise ValueError('Vehicle has no charging object')
raise ValueError("Vehicle has no climatization object")
url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}'
data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
if data is not None:
Expand Down Expand Up @@ -1064,10 +1077,165 @@ def fetch_air_conditioning(self, vehicle: SkodaVehicle, no_cache: bool = False)
else:
if isinstance(vehicle.climatization, SkodaClimatization):
vehicle.climatization.errors.clear()

# Handle runningRequests
if 'runningRequests' in data and data['runningRequests'] is not None:
if not isinstance(vehicle.climatization, SkodaClimatization):
vehicle.climatization = SkodaClimatization(origin=vehicle.climatization)
vehicle.climatization.running_requests._set_value(data['runningRequests'], measured=captured_at) # pylint: disable=protected-access
LOG_API.debug('Found %d running requests in air-conditioning data', len(data['runningRequests']))

# Handle timers - create individual timer objects for clean structure (same approach as auxiliary heating)
if 'timers' in data and data['timers'] is not None:
if not isinstance(vehicle.climatization, SkodaClimatization):
vehicle.climatization = SkodaClimatization(origin=vehicle.climatization)

# Create individual timer objects as attributes for clean hierarchical display
from carconnectivity.objects import GenericObject
from carconnectivity.attributes import BooleanAttribute, StringAttribute, GenericAttribute

for timer in data['timers']:
if 'id' in timer:
timer_id = timer['id']
timer_attr_name = f'active_ventilation_timer_{timer_id}'

# Only create if not already present (same approach as auxiliary heating)
if not hasattr(vehicle.climatization, timer_attr_name):
timer_obj = GenericObject(object_id=timer_attr_name, parent=vehicle.climatization)
timer_obj.timer_enabled = BooleanAttribute(name='timer_enabled', parent=timer_obj, value=False, tags={"connector_custom"})
timer_obj.time = StringAttribute(name='time', parent=timer_obj, value='00:00', tags={"connector_custom"})
timer_obj.type = StringAttribute(name='type', parent=timer_obj, value='ONE_OFF', tags={"connector_custom"})
timer_obj.selected_days = GenericAttribute(name='selected_days', parent=timer_obj, value=[], tags={"connector_custom"})
setattr(vehicle.climatization, timer_attr_name, timer_obj)

# Update existing timer object with new values
timer_obj = getattr(vehicle.climatization, timer_attr_name)
timer_obj.timer_enabled._set_value(timer.get('enabled', False), measured=captured_at) # pylint: disable=protected-access
timer_obj.time._set_value(timer.get('time', '00:00'), measured=captured_at) # pylint: disable=protected-access
timer_obj.type._set_value(timer.get('type', 'ONE_OFF'), measured=captured_at) # pylint: disable=protected-access
timer_obj.selected_days._set_value(timer.get('selectedDays', []), measured=captured_at) # pylint: disable=protected-access

LOG_API.debug('Found %d timers in air-conditioning data', len(data['timers']))
for i, timer in enumerate(data['timers']):
LOG_API.debug('Timer %d: id=%s, enabled=%s, time=%s, type=%s',
i, timer.get('id'), timer.get('enabled'), timer.get('time'), timer.get('type'))

log_extra_keys(LOG_API, 'air-condition', data, {'carCapturedTimestamp', 'state', 'estimatedDateTimeToReachTargetTemperature',
'targetTemperature', 'outsideTemperature', 'chargerConnectionState',
'chargerLockState', 'airConditioningAtUnlock', 'steeringWheelPosition',
'windowHeatingEnabled', 'seatHeatingActivated', 'windowHeatingState', 'errors'})
'windowHeatingEnabled', 'seatHeatingActivated', 'windowHeatingState', 'errors',
'runningRequests', 'timers'})
return vehicle

def fetch_auxiliary_heating(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
"""
Fetches the auxiliary heating data for a given Skoda vehicle and updates the vehicle object with the retrieved data.

Args:
vehicle (SkodaVehicle): The vehicle object for which to fetch auxiliary heating data.
no_cache (bool, optional): Whether to bypass cache. Defaults to False.

Returns:
SkodaVehicle: The updated vehicle object with the fetched auxiliary heating data.

Raises:
APIError: If the VIN is missing or if the carCapturedTimestamp is missing in the response data.
ValueError: If the vehicle has no climatization object.

Notes:
- The method fetches data from the Skoda auxiliary heating API using the vehicle's VIN.
- It updates the vehicle's auxiliary heating state, duration, and timers.
- Logs additional keys found in the response data for debugging purposes.
- If auxiliary heating is not available (403 error), this is handled gracefully.
"""
vin = vehicle.vin.value
if vin is None:
raise APIError('VIN is missing')

LOG.debug("Fetching auxiliary heating data for %s", vin)

if vehicle.climatization is None:
raise ValueError("Vehicle has no climatization object")

url = f'https://mysmob.api.connect.skoda-auto.cz/api/v2/air-conditioning/{vin}/auxiliary-heating'
try:
data: Dict[str, Any] | None = self._fetch_data(url=url, session=self.session, no_cache=no_cache)
except Exception as e:
# Auxiliary heating may not be available for all vehicles or accounts
# Handle this gracefully and log for debugging
if "403" in str(e) or "Forbidden" in str(e):
LOG.debug("Auxiliary heating not available for %s (403 Forbidden) - this may be normal for this vehicle", vin)
return vehicle
else:
# Re-raise other errors
raise e

if data is not None:
if 'carCapturedTimestamp' in data and data['carCapturedTimestamp'] is not None:
captured_at: datetime = robust_time_parse(data['carCapturedTimestamp'])
else:
raise APIError('Could not fetch auxiliary heating, carCapturedTimestamp missing')

# Add auxiliary heating state attribute to climatization if not already present
if not hasattr(vehicle.climatization, 'auxiliary_heating_state'):
from carconnectivity.objects import GenericObject
from carconnectivity.attributes import StringAttribute

aux_heating_obj = GenericObject(object_id='auxiliary_heating_state', parent=vehicle.climatization)
aux_heating_obj.state = StringAttribute(name='state', parent=aux_heating_obj, value='UNKNOWN', tags=['auxiliary_heating'])
aux_heating_obj.duration_seconds = StringAttribute(name='duration_seconds', parent=aux_heating_obj, value='0', tags=['auxiliary_heating'])
setattr(vehicle.climatization, 'auxiliary_heating_state', aux_heating_obj)

# Update auxiliary heating state
if 'state' in data and data['state'] is not None:
vehicle.climatization.auxiliary_heating_state.state._set_value(value=str(data['state']), measured=captured_at) # pylint: disable=protected-access
else:
vehicle.climatization.auxiliary_heating_state.state._set_value(value='UNKNOWN', measured=captured_at) # pylint: disable=protected-access

# Update duration in seconds
if 'durationInSeconds' in data and data['durationInSeconds'] is not None:
vehicle.climatization.auxiliary_heating_state.duration_seconds._set_value(value=str(data['durationInSeconds']), measured=captured_at) # pylint: disable=protected-access
else:
vehicle.climatization.auxiliary_heating_state.duration_seconds._set_value(value='0', measured=captured_at) # pylint: disable=protected-access

# Handle auxiliary heating timers similar to air conditioning timers
if 'timers' in data and isinstance(data['timers'], list):
from carconnectivity.objects import GenericObject
from carconnectivity.attributes import BooleanAttribute, StringAttribute

# Create timer objects for auxiliary heating
for timer in data['timers']:
if 'id' in timer:
timer_id = timer['id']
timer_attr_name = f'auxiliary_heating_timer_{timer_id}'

# Only create if not already present
if not hasattr(vehicle.climatization, timer_attr_name):
timer_obj = GenericObject(object_id=timer_attr_name, parent=vehicle.climatization)
timer_obj.timer_enabled = BooleanAttribute(name='timer_enabled', parent=timer_obj, value=False, tags=['auxiliary_heating'])
timer_obj.time = StringAttribute(name='time', parent=timer_obj, value='00:00', tags=['auxiliary_heating'])
timer_obj.type = StringAttribute(name='type', parent=timer_obj, value='ONE_OFF', tags=['auxiliary_heating'])
timer_obj.selected_days = StringAttribute(name='selected_days', parent=timer_obj, value='', tags=['auxiliary_heating'])
setattr(vehicle.climatization, timer_attr_name, timer_obj)

# Update timer object
timer_obj = getattr(vehicle.climatization, timer_attr_name)
if 'enabled' in timer:
timer_obj.timer_enabled._set_value(value=bool(timer['enabled']), measured=captured_at) # pylint: disable=protected-access
if 'time' in timer:
timer_obj.time._set_value(value=str(timer['time']), measured=captured_at) # pylint: disable=protected-access
if 'type' in timer:
timer_obj.type._set_value(value=str(timer['type']), measured=captured_at) # pylint: disable=protected-access
if 'selectedDays' in timer:
timer_obj.selected_days._set_value(value=str(timer.get('selectedDays', '')), measured=captured_at) # pylint: disable=protected-access

LOG_API.debug('Found %d auxiliary heating timers', len(data['timers']))
for i, timer in enumerate(data['timers']):
LOG_API.debug('Auxiliary Heating Timer %d: id=%s, enabled=%s, time=%s, type=%s',
i, timer.get('id'), timer.get('enabled'), timer.get('time'), timer.get('type'))

log_extra_keys(LOG_API, 'auxiliary-heating', data, {'carCapturedTimestamp', 'state', 'durationInSeconds',
'timers', 'outsideTemperature', 'errors'})
return vehicle

def fetch_vehicle_details(self, vehicle: SkodaVehicle, no_cache: bool = False) -> SkodaVehicle:
Expand Down
1 change: 1 addition & 0 deletions src/carconnectivity_connectors/skoda/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ class ClimatizationError(Enum):
that correspond to different climatization issues.
"""
UNAVAILABLE_CHARGING_INFORMATION = 'UNAVAILABLE_CHARGING_INFORMATION'
UNAVAILABLE_VEHICLE_INFORMATION = 'UNAVAILABLE_VEHICLE_INFORMATION'
UNKNOWN = 'UNKNOWN'