Skip to content

Add State Change Tracking for Device Automations #2

@adamjacobmuller

Description

@adamjacobmuller

Add State Change Tracking for Device Automations

🎯 Objective

Implement state change tracking in pytryfi to support Home Assistant device automations. This will enable triggers like "pet arrived home", "battery dropped below 20%", and "collar disconnected".

📋 Background

Home Assistant device automations need to know when states change, not just current values. Adding timestamps and history tracking will enable powerful automation capabilities.

🔧 Implementation Plan

1. Add State Change Tracking to FiPet

Update fiPet.py:

class FiPet(object):
    def __init__(self, petId):
        # ... existing init code ...
        
        # State tracking
        self._location_history = []  # List of (timestamp, location_name, lat, lon)
        self._activity_history = []  # List of (timestamp, activity_type)
        self._state_changes = {}  # Dict of state_type: (old_value, new_value, timestamp)
        self._last_location_change = None
        self._last_activity_change = None
        self._last_lost_mode_change = None
    
    def _record_state_change(self, state_type: str, old_value: Any, new_value: Any):
        """Record state changes for automation triggers."""
        if old_value != new_value:
            timestamp = datetime.datetime.now(datetime.timezone.utc)
            self._state_changes[state_type] = {
                'old_value': old_value,
                'new_value': new_value,
                'timestamp': timestamp,
                'duration': self._calculate_duration(state_type, timestamp)
            }
            return True
        return False
    
    def _calculate_duration(self, state_type: str, current_time):
        """Calculate how long the previous state lasted."""
        if state_type in self._state_changes:
            prev_change = self._state_changes[state_type]['timestamp']
            return (current_time - prev_change).total_seconds()
        return None
    
    def setCurrentLocation(self, activityJSON):
        """Set current location with history tracking."""
        # Store previous location
        old_location = getattr(self, '_currPlaceName', None)
        old_coords = (getattr(self, '_currLatitude', None), 
                     getattr(self, '_currLongitude', None))
        
        # ... existing location setting code ...
        
        # Track location change
        if self._record_state_change('location', old_location, self._currPlaceName):
            self._last_location_change = datetime.datetime.now(datetime.timezone.utc)
            
            # Add to history
            self._location_history.append({
                'timestamp': self._last_location_change,
                'location_name': self._currPlaceName,
                'address': self._currPlaceAddress,
                'latitude': self._currLatitude,
                'longitude': self._currLongitude,
                'previous_location': old_location,
                'distance_moved': self._calculate_distance(old_coords, 
                    (self._currLatitude, self._currLongitude))
            })
            
            # Keep only last 50 location changes
            self._location_history = self._location_history[-50:]
    
    def _calculate_distance(self, coord1, coord2):
        """Calculate distance between two coordinates in meters."""
        if not all(coord1) or not all(coord2):
            return None
        
        # Haversine formula
        from math import radians, sin, cos, sqrt, atan2
        
        lat1, lon1 = radians(coord1[0]), radians(coord1[1])
        lat2, lon2 = radians(coord2[0]), radians(coord2[1])
        
        dlat = lat2 - lat1
        dlon = lon2 - lon1
        
        a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
        c = 2 * atan2(sqrt(a), sqrt(1-a))
        
        # Earth's radius in meters
        return 6371000 * c
    
    @property
    def location_history(self):
        """Get location history."""
        return self._location_history.copy()
    
    @property
    def last_location_change(self):
        """Get timestamp of last location change."""
        return self._last_location_change
    
    @property
    def time_at_current_location(self):
        """Get time spent at current location in seconds."""
        if self._last_location_change:
            return (datetime.datetime.now(datetime.timezone.utc) - 
                   self._last_location_change).total_seconds()
        return None

2. Add State Change Tracking to FiDevice

Update fiDevice.py:

class FiDevice(object):
    def __init__(self, deviceId):
        # ... existing init code ...
        
        # State tracking
        self._battery_history = []  # List of (timestamp, percentage, is_charging)
        self._connection_history = []  # List of (timestamp, state, signal_strength)
        self._led_history = []  # List of (timestamp, on/off, color)
        self._state_changes = {}
        self._last_battery_change = None
        self._last_connection_change = None
        self._last_led_change = None
        self._last_charge_start = None
        self._last_charge_end = None
    
    def setDeviceDetailsJSON(self, deviceJSON: dict):
        """Set device details with state tracking."""
        # Track previous values
        old_battery = getattr(self, '_batteryPercent', None)
        old_charging = getattr(self, '_isCharging', None)
        old_connection = getattr(self, '_connectionStateType', None)
        old_led = getattr(self, '_ledOn', None)
        old_led_color = getattr(self, '_ledColor', None)
        
        # ... existing device setting code ...
        
        # Track battery changes
        if old_battery != self._batteryPercent:
            self._record_battery_change(old_battery, self._batteryPercent, 
                                      old_charging, self._isCharging)
        
        # Track connection changes
        if old_connection != self._connectionStateType:
            self._record_connection_change(old_connection, self._connectionStateType)
        
        # Track LED changes
        if old_led != self._ledOn or old_led_color != self._ledColor:
            self._record_led_change(old_led, self._ledOn, old_led_color, self._ledColor)
    
    def _record_battery_change(self, old_percent, new_percent, old_charging, new_charging):
        """Record battery state changes."""
        timestamp = datetime.datetime.now(datetime.timezone.utc)
        
        # Track charging sessions
        if new_charging and not old_charging:
            self._last_charge_start = timestamp
        elif not new_charging and old_charging:
            self._last_charge_end = timestamp
        
        self._battery_history.append({
            'timestamp': timestamp,
            'percentage': new_percent,
            'is_charging': new_charging,
            'change': new_percent - old_percent if old_percent else 0,
            'drain_rate': self._calculate_drain_rate() if not new_charging else None
        })
        
        # Keep last 200 battery readings
        self._battery_history = self._battery_history[-200:]
        
        self._last_battery_change = timestamp
        self._state_changes['battery'] = {
            'old_value': old_percent,
            'new_value': new_percent,
            'timestamp': timestamp
        }
    
    def _calculate_drain_rate(self):
        """Calculate battery drain rate per hour."""
        if len(self._battery_history) < 2:
            return None
        
        # Find recent non-charging periods
        recent_history = [h for h in self._battery_history[-20:] 
                         if not h['is_charging']]
        
        if len(recent_history) < 2:
            return None
        
        first = recent_history[0]
        last = recent_history[-1]
        
        time_diff = (last['timestamp'] - first['timestamp']).total_seconds() / 3600
        battery_diff = first['percentage'] - last['percentage']
        
        return battery_diff / time_diff if time_diff > 0 else None
    
    @property
    def battery_history(self):
        """Get battery history."""
        return self._battery_history.copy()
    
    @property
    def estimated_battery_life(self):
        """Estimate remaining battery life in hours."""
        drain_rate = self._calculate_drain_rate()
        if drain_rate and drain_rate > 0 and self._batteryPercent:
            return self._batteryPercent / drain_rate
        return None
    
    @property
    def last_charge_duration(self):
        """Get duration of last charge session."""
        if self._last_charge_start and self._last_charge_end:
            if self._last_charge_end > self._last_charge_start:
                return (self._last_charge_end - self._last_charge_start).total_seconds()
        return None

3. Add Lost Mode Tracking

Update fiPet.py to track lost mode changes:

def setLostDogMode(self, sessionId: requests.Session, action):
    """Set lost dog mode with state tracking."""
    old_lost_mode = self.isLost
    
    # ... existing lost mode code ...
    
    if self._record_state_change('lost_mode', old_lost_mode, self.isLost):
        self._last_lost_mode_change = datetime.datetime.now(datetime.timezone.utc)
        
        # Send notification-worthy event
        self._state_changes['lost_mode']['severity'] = 'high'
        self._state_changes['lost_mode']['action_taken'] = action

4. Add Methods to Query State Changes

def get_recent_state_changes(self, hours=24):
    """Get all state changes in the last N hours."""
    cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=hours)
    recent_changes = {}
    
    for state_type, change_info in self._state_changes.items():
        if change_info['timestamp'] > cutoff:
            recent_changes[state_type] = change_info
    
    return recent_changes

def has_state_changed(self, state_type, since_timestamp):
    """Check if a specific state has changed since a timestamp."""
    if state_type in self._state_changes:
        return self._state_changes[state_type]['timestamp'] > since_timestamp
    return False

📝 Benefits

  1. Device Automations: Enable "arrived", "left", "battery low" triggers
  2. History Tracking: See patterns in pet behavior
  3. Duration Tracking: Know how long pet has been at location
  4. Battery Analytics: Predict when charging needed
  5. Connection Monitoring: Track collar reliability

🧪 Testing

def test_location_change_tracking():
    """Test location changes are tracked."""
    pet = FiPet("test123")
    
    # Set initial location
    pet.setCurrentLocation({"place": {"name": "Home"}})
    
    # Change location
    pet.setCurrentLocation({"place": {"name": "Park"}})
    
    assert pet.last_location_change is not None
    assert len(pet.location_history) == 1
    assert pet.location_history[0]['previous_location'] == "Home"
    assert pet.location_history[0]['location_name'] == "Park"

def test_battery_tracking():
    """Test battery changes are tracked."""
    device = FiDevice("test123")
    
    # Set initial battery
    device.setDeviceDetailsJSON({"info": {"batteryPercent": 100}})
    
    # Drain battery
    device.setDeviceDetailsJSON({"info": {"batteryPercent": 80}})
    
    assert len(device.battery_history) == 1
    assert device.battery_history[0]['change'] == -20

📋 Checklist

  • Add location history tracking
  • Add activity history tracking
  • Add battery history tracking
  • Add connection history tracking
  • Add state change timestamps
  • Add duration calculations
  • Add distance calculations
  • Add drain rate calculations
  • Add history query methods
  • Add tests for all tracking
  • Document new properties
  • Ensure thread safety

🏷️ Labels

enhancement, feature, device-automation

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions