Add Baby Tracker client and Home Assistant integration
Python client for the Baby Tracker sync server, reverse engineered from the Android app (4.53), and a HACS-installable Home Assistant integration built on it. Each baby gets a log calendar and summary sensors, and actions add entries that sync back to the group. Reading has been run against the real server. Uploading and running inside real Home Assistant have not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6466f002e0
51 files changed
+5199
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ...exceptions import HomeAssistantError
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarEvent:
|
||||
start: datetime
|
||||
end: datetime
|
||||
summary: str
|
||||
description: Optional[str] = None
|
||||
uid: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise HomeAssistantError("Expected all values to have a timezone")
|
||||
if self.end <= self.start:
|
||||
raise HomeAssistantError("Expected end time to be after start time")
|
||||
|
||||
|
||||
class CalendarEntity:
|
||||
pass
|
||||
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class SensorDeviceClass(str, Enum):
|
||||
DISTANCE = "distance"
|
||||
DURATION = "duration"
|
||||
TEMPERATURE = "temperature"
|
||||
TIMESTAMP = "timestamp"
|
||||
VOLUME = "volume"
|
||||
WEIGHT = "weight"
|
||||
|
||||
|
||||
class SensorStateClass(str, Enum):
|
||||
MEASUREMENT = "measurement"
|
||||
TOTAL_INCREASING = "total_increasing"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SensorEntityDescription:
|
||||
key: str
|
||||
device_class: Optional[SensorDeviceClass] = None
|
||||
state_class: Optional[SensorStateClass] = None
|
||||
native_unit_of_measurement: Optional[Any] = None
|
||||
suggested_display_precision: Optional[int] = None
|
||||
|
||||
|
||||
class SensorEntity:
|
||||
pass
|
||||
@@ -0,0 +1,63 @@
|
||||
from enum import Enum
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from .data_entry_flow import AbortFlow
|
||||
|
||||
_T = TypeVar("_T")
|
||||
ConfigFlowResult = dict
|
||||
|
||||
|
||||
class ConfigEntryState(Enum):
|
||||
LOADED = "loaded"
|
||||
NOT_LOADED = "not_loaded"
|
||||
|
||||
|
||||
class ConfigEntry(Generic[_T]):
|
||||
def __init__(self, data, unique_id, title="entry"):
|
||||
self.data = dict(data)
|
||||
self.unique_id = unique_id
|
||||
self.title = title
|
||||
self.state = ConfigEntryState.NOT_LOADED
|
||||
self.runtime_data = None
|
||||
self.unloads = []
|
||||
|
||||
def async_on_unload(self, func):
|
||||
self.unloads.append(func)
|
||||
|
||||
|
||||
class ConfigFlow:
|
||||
def __init_subclass__(cls, domain=None, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls.domain = domain
|
||||
|
||||
def __init__(self):
|
||||
self.hass = None
|
||||
self.unique_id = None
|
||||
self.existing_unique_ids = set()
|
||||
self.reauth_entry = None
|
||||
self.updated = None
|
||||
|
||||
def async_show_form(self, step_id, data_schema=None, errors=None, description_placeholders=None):
|
||||
return {"type": "form", "step_id": step_id, "errors": errors or {}, "schema": data_schema,
|
||||
"description_placeholders": description_placeholders}
|
||||
|
||||
def async_create_entry(self, title, data):
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
async def async_set_unique_id(self, unique_id):
|
||||
self.unique_id = unique_id
|
||||
|
||||
def _abort_if_unique_id_configured(self):
|
||||
if self.unique_id in self.existing_unique_ids:
|
||||
raise AbortFlow("already_configured")
|
||||
|
||||
def _abort_if_unique_id_mismatch(self, reason="unique_id_mismatch"):
|
||||
if self.reauth_entry.unique_id != self.unique_id:
|
||||
raise AbortFlow(reason)
|
||||
|
||||
def _get_reauth_entry(self):
|
||||
return self.reauth_entry
|
||||
|
||||
def async_update_reload_and_abort(self, entry, data_updates=None):
|
||||
entry.data.update(data_updates or {})
|
||||
return {"type": "abort", "reason": "reauth_successful"}
|
||||
@@ -0,0 +1,29 @@
|
||||
from enum import Enum
|
||||
|
||||
CONF_EMAIL = "email"
|
||||
CONF_PASSWORD = "password"
|
||||
|
||||
|
||||
class Platform(str, Enum):
|
||||
CALENDAR = "calendar"
|
||||
SENSOR = "sensor"
|
||||
|
||||
|
||||
class UnitOfLength(str, Enum):
|
||||
CENTIMETERS = "cm"
|
||||
|
||||
|
||||
class UnitOfMass(str, Enum):
|
||||
KILOGRAMS = "kg"
|
||||
|
||||
|
||||
class UnitOfTemperature(str, Enum):
|
||||
CELSIUS = "°C"
|
||||
|
||||
|
||||
class UnitOfTime(str, Enum):
|
||||
MINUTES = "min"
|
||||
|
||||
|
||||
class UnitOfVolume(str, Enum):
|
||||
MILLILITERS = "mL"
|
||||
@@ -0,0 +1,50 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
ServiceResponse = Optional[dict]
|
||||
|
||||
|
||||
def callback(func):
|
||||
return func
|
||||
|
||||
|
||||
class SupportsResponse(Enum):
|
||||
NONE = "none"
|
||||
OPTIONAL = "optional"
|
||||
ONLY = "only"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceCall:
|
||||
data: dict = field(default_factory=dict)
|
||||
return_response: bool = False
|
||||
|
||||
|
||||
class _Services:
|
||||
def __init__(self):
|
||||
self.registered = {}
|
||||
|
||||
def async_register(self, domain, service, handler, schema=None, supports_response=SupportsResponse.NONE):
|
||||
self.registered[(domain, service)] = (handler, schema, supports_response)
|
||||
|
||||
async def async_call(self, domain, service, data=None, return_response=False):
|
||||
handler, schema, _ = self.registered[(domain, service)]
|
||||
validated = schema(dict(data or {})) if schema else dict(data or {})
|
||||
return await handler(ServiceCall(validated, return_response))
|
||||
|
||||
|
||||
class _Config:
|
||||
def __init__(self, units):
|
||||
self.units = units
|
||||
|
||||
|
||||
class HomeAssistant:
|
||||
def __init__(self, units):
|
||||
self.config = _Config(units)
|
||||
self.services = _Services()
|
||||
self.config_entries = None
|
||||
|
||||
async def async_add_executor_job(self, target, *args):
|
||||
return await asyncio.get_running_loop().run_in_executor(None, target, *args)
|
||||
@@ -0,0 +1,4 @@
|
||||
class AbortFlow(Exception):
|
||||
def __init__(self, reason):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
@@ -0,0 +1,18 @@
|
||||
class HomeAssistantError(Exception):
|
||||
def __init__(self, *args, translation_domain=None, translation_key=None, translation_placeholders=None):
|
||||
super().__init__(*args)
|
||||
self.translation_domain = translation_domain
|
||||
self.translation_key = translation_key
|
||||
self.translation_placeholders = translation_placeholders
|
||||
|
||||
|
||||
class ServiceValidationError(HomeAssistantError):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigEntryAuthFailed(HomeAssistantError):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigEntryNotReady(HomeAssistantError):
|
||||
pass
|
||||
Whitespace-only changes.
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime as datetime_sys
|
||||
|
||||
import voluptuous
|
||||
|
||||
|
||||
def string(value):
|
||||
if value is None:
|
||||
raise voluptuous.Invalid("string value is None")
|
||||
return str(value)
|
||||
|
||||
|
||||
def datetime(value):
|
||||
if isinstance(value, datetime_sys):
|
||||
return value
|
||||
try:
|
||||
return datetime_sys.fromisoformat(value)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise voluptuous.Invalid("invalid datetime") from err
|
||||
|
||||
|
||||
def config_entry_only_config_schema(domain):
|
||||
return lambda config: config
|
||||
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DeviceEntryType(Enum):
|
||||
SERVICE = "service"
|
||||
|
||||
|
||||
def DeviceInfo(**kwargs):
|
||||
return kwargs
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceEntry:
|
||||
identifiers: set
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
def __init__(self):
|
||||
self.devices = {}
|
||||
|
||||
def async_get(self, device_id):
|
||||
return self.devices.get(device_id)
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry()
|
||||
|
||||
|
||||
def async_get(hass):
|
||||
return REGISTRY
|
||||
@@ -0,0 +1,18 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TextSelectorType(str, Enum):
|
||||
EMAIL = "email"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
def TextSelectorConfig(**kwargs):
|
||||
return kwargs
|
||||
|
||||
|
||||
class TextSelector:
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, value):
|
||||
return str(value)
|
||||
@@ -0,0 +1 @@
|
||||
ConfigType = dict
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from ..exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class UpdateFailed(HomeAssistantError):
|
||||
pass
|
||||
|
||||
|
||||
class DataUpdateCoordinator(Generic[_T]):
|
||||
def __init__(self, hass, logger, *, config_entry=None, name=None, update_interval=None):
|
||||
self.hass = hass
|
||||
self.logger = logger
|
||||
self.config_entry = config_entry
|
||||
self.name = name
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = True
|
||||
self.last_exception = None
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, listener):
|
||||
self._listeners.append(listener)
|
||||
return lambda: self._listeners.remove(listener)
|
||||
|
||||
def _notify(self):
|
||||
for listener in list(self._listeners):
|
||||
listener()
|
||||
|
||||
async def async_refresh(self):
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
except UpdateFailed as err:
|
||||
self.last_update_success = False
|
||||
self.last_exception = err
|
||||
else:
|
||||
self.last_update_success = True
|
||||
self._notify()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
except UpdateFailed as err:
|
||||
raise ConfigEntryNotReady(str(err)) from err
|
||||
|
||||
def async_set_updated_data(self, data):
|
||||
self.data = data
|
||||
self.last_update_success = True
|
||||
self._notify()
|
||||
|
||||
|
||||
class CoordinatorEntity(Generic[_T]):
|
||||
def __init__(self, coordinator):
|
||||
self.coordinator = coordinator
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.coordinator.last_update_success
|
||||
Whitespace-only changes.
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DEFAULT_TIME_ZONE = timezone.utc
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def as_utc(value):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=DEFAULT_TIME_ZONE)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def as_local(value):
|
||||
return value.astimezone(DEFAULT_TIME_ZONE)
|
||||
|
||||
|
||||
def start_of_local_day():
|
||||
return utcnow().astimezone(DEFAULT_TIME_ZONE).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
@@ -0,0 +1,6 @@
|
||||
class UnitSystem:
|
||||
pass
|
||||
|
||||
|
||||
METRIC_SYSTEM = UnitSystem()
|
||||
US_CUSTOMARY_SYSTEM = UnitSystem()
|
||||
Reference in new issue
Block a user