Skip to content

Commit e465b89

Browse files
committed
feat: split single balance sensor into "You Owe" and "You Are Owed" sensors
Replaces the single net-balance "Splitwise Balance" sensor with two dedicated sensors matching Splitwise's own dashboard split: "Splitwise You Owe" and "Splitwise You Are Owed", each a positive magnitude with its own per-friend/per-group breakdown attributes filtered to that side. Introduces a DataUpdateCoordinator (coordinator.py) to fetch and aggregate Splitwise data once per 30-minute cycle, shared by both sensor entities via CoordinatorEntity, instead of each entity polling independently. This also means the splitwise_notification_event_* bus events keep firing exactly once per cycle rather than being duplicated across sensors. Breaking change: removes the "Splitwise Balance" entity (unique_id "<entry_id>_balance"). Bumped to 0.3.0.
1 parent b667de9 commit e465b89

5 files changed

Lines changed: 315 additions & 222 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ As of version 0.2.0, this integration is configured entirely through the Home As
4949
1. Go to **Settings > Devices & Services > Application Credentials** and add a credential for **Splitwise**, using the Consumer Key/Secret from the app you registered above.
5050
2. Go to **Settings > Devices & Services > Add Integration**, search for **Splitwise**, and follow the prompts.
5151
3. You'll be redirected to Splitwise to authorize Home Assistant, then redirected back automatically once you approve.
52-
4. The sensor will populate with your balance and per-friend/per-group attributes shortly after.
52+
4. Two sensors will populate shortly after: **Splitwise You Owe** and **Splitwise You Are Owed**, each with per-friend/per-group breakdown attributes for that side of the balance.
5353

5454
## Final Output
5555
![dash-url](images/dash.png)

custom_components/splitwise/__init__.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import logging
6-
from dataclasses import dataclass
76

87
import homeassistant.helpers.config_validation as cv
98
from homeassistant.config_entries import ConfigEntry
@@ -14,6 +13,7 @@
1413
from splitwise import Splitwise
1514

1615
from .const import DOMAIN
16+
from .coordinator import SplitwiseDataUpdateCoordinator
1717

1818
_LOGGER = logging.getLogger(__name__)
1919

@@ -22,14 +22,6 @@
2222
PLATFORMS = [Platform.SENSOR]
2323

2424

25-
@dataclass
26-
class SplitwiseRuntimeData:
27-
"""Runtime data bundled for the config entry."""
28-
29-
session: config_entry_oauth2_flow.OAuth2Session
30-
client: Splitwise
31-
32-
3325
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
3426
"""Set up the Splitwise component, warning about deprecated YAML config."""
3527
for platform_conf in config.get("sensor", []):
@@ -62,9 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
6254
consumer_secret=implementation.client_secret,
6355
)
6456

65-
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SplitwiseRuntimeData(
66-
session, client
67-
)
57+
coordinator = SplitwiseDataUpdateCoordinator(hass, entry, session, client)
58+
await coordinator.async_config_entry_first_refresh()
59+
60+
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
6861

6962
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
7063

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Data update coordinator for Splitwise."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from dataclasses import dataclass, field
7+
from datetime import timedelta
8+
9+
from homeassistant.config_entries import ConfigEntry
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.exceptions import ConfigEntryAuthFailed
12+
from homeassistant.helpers import config_entry_oauth2_flow
13+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
14+
from splitwise import Splitwise
15+
from splitwise.exception import SplitwiseException
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
SCAN_INTERVAL = timedelta(minutes=30)
20+
21+
22+
def _sum_by_currency(amounts):
23+
"""Sum a list of (currency_code, amount) pairs, keyed by currency."""
24+
totals: dict[str, float] = {}
25+
for currency_code, amount in amounts:
26+
totals[currency_code] = totals.get(currency_code, 0.0) + amount
27+
return totals
28+
29+
30+
@dataclass
31+
class SplitwiseBalanceEntry:
32+
"""A friend's or group's balance, in the account's default currency."""
33+
34+
name: str
35+
balance: float
36+
balances_by_currency: dict[str, float]
37+
id: int | None = None
38+
39+
40+
@dataclass
41+
class SplitwiseData:
42+
"""Aggregated Splitwise data for a single account."""
43+
44+
user_id: int
45+
first_name: str
46+
last_name: str
47+
currency: str
48+
you_owe: float
49+
you_are_owed: float
50+
friends: list[SplitwiseBalanceEntry] = field(default_factory=list)
51+
groups: list[SplitwiseBalanceEntry] = field(default_factory=list)
52+
53+
54+
class SplitwiseDataUpdateCoordinator(DataUpdateCoordinator[SplitwiseData]):
55+
"""Fetches and aggregates Splitwise data once per interval for all sensors."""
56+
57+
def __init__(
58+
self,
59+
hass: HomeAssistant,
60+
entry: ConfigEntry,
61+
session: config_entry_oauth2_flow.OAuth2Session,
62+
client: Splitwise,
63+
) -> None:
64+
super().__init__(
65+
hass,
66+
_LOGGER,
67+
name="Splitwise",
68+
update_interval=SCAN_INTERVAL,
69+
)
70+
self.entry = entry
71+
self.session = session
72+
self.client = client
73+
74+
def _fetch_splitwise_data(self, token):
75+
"""Run on the executor: sync the token into the client and fetch data."""
76+
client = self.client
77+
client.setOAuth2AccessToken(token)
78+
79+
user = client.getCurrentUser()
80+
friends = client.getFriends()
81+
groups = client.getGroups()
82+
83+
try:
84+
notifications = client.getNotifications()
85+
except Exception as err: # noqa: BLE001 - the splitwise library can raise
86+
# non-SplitwiseException errors (e.g. KeyError) when a notification's
87+
# data is missing fields it assumes are always present. Don't let a
88+
# malformed notification take down the whole update.
89+
_LOGGER.warning("Failed to fetch Splitwise notifications: %s", err)
90+
notifications = []
91+
92+
return user, friends, groups, notifications
93+
94+
async def _async_update_data(self) -> SplitwiseData:
95+
try:
96+
await self.session.async_ensure_token_valid()
97+
user, friends, groups, notifications = await self.hass.async_add_executor_job(
98+
self._fetch_splitwise_data, self.session.token
99+
)
100+
except SplitwiseException as err:
101+
raise ConfigEntryAuthFailed(
102+
f"Splitwise authentication failed: {err}"
103+
) from err
104+
105+
currency = user.getDefaultCurrency()
106+
first_name = user.getFirstName().title().lower()
107+
last_name = user.getLastName().title().lower()
108+
id_map = {user.getId(): first_name}
109+
110+
you_owe = 0.0
111+
you_are_owed = 0.0
112+
friend_entries: list[SplitwiseBalanceEntry] = []
113+
114+
for f in friends:
115+
name = f.getFirstName().title().lower()
116+
friend_id = f.getId()
117+
id_map[friend_id] = name
118+
119+
balances_by_currency = _sum_by_currency(
120+
(b.getCurrencyCode(), float(b.getAmount())) for b in f.getBalances()
121+
)
122+
balance = balances_by_currency.get(currency, 0.0)
123+
124+
if balance < 0:
125+
you_owe += -balance
126+
elif balance > 0:
127+
you_are_owed += balance
128+
129+
other_currencies = {
130+
code: amount
131+
for code, amount in balances_by_currency.items()
132+
if code != currency and amount != 0.0
133+
}
134+
if balance != 0.0 or other_currencies:
135+
friend_entries.append(
136+
SplitwiseBalanceEntry(
137+
name=name.strip(),
138+
balance=balance,
139+
balances_by_currency=other_currencies,
140+
id=friend_id,
141+
)
142+
)
143+
144+
group_entries: list[SplitwiseBalanceEntry] = []
145+
146+
for g in groups:
147+
amounts_by_currency = []
148+
for d in g.getOriginalDebts():
149+
# currency_code is optional on Debt; treat missing as the
150+
# account's default currency rather than dropping it.
151+
currency_code = d.getCurrencyCode() or currency
152+
153+
if id_map.get(d.getToUser()) == first_name:
154+
amounts_by_currency.append((currency_code, -float(d.getAmount())))
155+
elif id_map.get(d.getFromUser()) == first_name:
156+
amounts_by_currency.append((currency_code, float(d.getAmount())))
157+
158+
balances_by_currency = _sum_by_currency(amounts_by_currency)
159+
balance = balances_by_currency.get(currency, 0.0)
160+
other_currencies = {
161+
code: amount
162+
for code, amount in balances_by_currency.items()
163+
if code != currency and amount != 0.0
164+
}
165+
if balance != 0.0 or other_currencies:
166+
group_entries.append(
167+
SplitwiseBalanceEntry(
168+
name=g.getName().strip(),
169+
balance=balance,
170+
balances_by_currency=other_currencies,
171+
)
172+
)
173+
174+
self._emit_notifications(notifications)
175+
176+
if self.entry.unique_id is None:
177+
self.hass.config_entries.async_update_entry(
178+
self.entry, unique_id=str(user.getId())
179+
)
180+
181+
return SplitwiseData(
182+
user_id=user.getId(),
183+
first_name=first_name,
184+
last_name=last_name,
185+
currency=currency,
186+
you_owe=you_owe,
187+
you_are_owed=you_are_owed,
188+
friends=friend_entries,
189+
groups=group_entries,
190+
)
191+
192+
def _emit_notifications(self, notifications):
193+
for n in notifications:
194+
self.hass.bus.fire(
195+
"splitwise_notification_event_" + str(n.getType()),
196+
{
197+
"id": n.getId(),
198+
"type": n.getType(),
199+
"image_url": n.getImageUrl(),
200+
"content": n.getContent(),
201+
"image_shape": n.getImageShape(),
202+
"created_at": n.getCreatedAt(),
203+
"created_by": n.getCreatedBy(),
204+
"source": {
205+
"id": n.source.getId(),
206+
"type": n.source.getType(),
207+
"url": n.source.getUrl(),
208+
},
209+
},
210+
origin="REMOTE",
211+
)

custom_components/splitwise/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"iot_class": "cloud_polling",
99
"issue_tracker": "https://github.com/sriramsv/custom_component_splitwise/issues",
1010
"requirements": ["splitwise==3.0.0"],
11-
"version": "0.2.10"
11+
"version": "0.3.0"
1212
}

0 commit comments

Comments
 (0)