├── .gitignore ├── LICENSE ├── README.md ├── custom_components └── splitwise │ ├── __init__.py │ ├── const.py │ ├── manifest.json │ └── sensor.py ├── hacs.json └── images ├── account.png ├── auth-url.png ├── dash.png ├── edit-app.png ├── oauth.png ├── profile.png └── register.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sriram S V 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom component for HomeAssistant for Splitwise API 2 | 3 | This is a custom component integration for Splitwise API 4 | 5 | 6 | 7 | ### Splitwise Setup 8 | - Login into https://secure.splitwise.com and login into your account 9 | - Click on your `profile` and select `Your Account` 10 | 11 | 12 | ![Account](images/account.png) 13 | 14 | - In the `Your Account` section, select `Your Apps` under the `Privacy and Security` section 15 | 16 | ![Profile](images/profile.png) 17 | 18 | - Under the build your own app, click on `Register your Application` 19 | 20 | ![Register](images/register.png) 21 | 22 | 23 | - Fill in the following sections 24 | - Application name: Homeassistant 25 | - Application Description: Homeassistant API Integration 26 | - Homepage URL: `http://localhost:8123` 27 | - [Important] Callback URL: `http://localhost:8123/api/splitwise/callback` 28 | 29 | #### Note: 30 | 31 | If you are using a reverse proxy in front of your home assistant server, use the public address (`https://home..com>/api/splitwise/callback`) as the callback URL in the application settings. Otherwise the oauth authentication won't work 32 | 33 | ![edit-app](images/edit-app.png) 34 | - Click on `Register and get API key` 35 | - Copy the `Consumer Key` and `Consumer Secret` values and store it some place safe 36 | 37 | 38 | ## Installation 39 | 40 | ### HACS: 41 | - Search for `Splitwise Sensor` in HACS console and install it. 42 | 43 | ### Manual 44 | - Copy the contents of the folder `custom_components/splitwise` into `/custom_components/splitwise` 45 | 46 | - Add the following lines to the `configuration.yaml` 47 | 48 | 49 | ## Configuration 50 | ```yaml 51 | sensor: 52 | - platform: splitwise 53 | client_id: '' 54 | client_secret: '' 55 | ``` 56 | 57 | - Restart Homeassistant 58 | - Once you login to Homeassistant again, you should see a persistent notification with an authorization URL link in it: 59 | 60 | 61 | ![auth-url](images/auth-url.png) 62 | 63 | - You will be redirected to the Oauth confirmation page from Splitwise to authorize Homeassistant to pull the data on your behalf. 64 | 65 | ![oauth-confirm](images/oauth.png) 66 | - Once you accept the Splitwise Oauth Callback, then sensor pulls the data from Splitwise API 67 | 68 | ## Final Output 69 | ![dash-url](images/dash.png) 70 | 71 | # Advanced usage - events 72 | 73 | This component will fire events: 74 | 75 | ![image](https://user-images.githubusercontent.com/365751/205475209-30e938f0-e1d2-4067-ae36-bbefba85ba18.png) 76 | 77 | The event types are defined in the [API documentation](https://dev.splitwise.com/#tag/notifications) 78 | -------------------------------------------------------------------------------- /custom_components/splitwise/__init__.py: -------------------------------------------------------------------------------- 1 | """ Splitwise Sensor Custom Component""" 2 | -------------------------------------------------------------------------------- /custom_components/splitwise/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "splitwise" 2 | -------------------------------------------------------------------------------- /custom_components/splitwise/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "splitwise", 3 | "name": "Splitwise", 4 | "documentation": "https://github.com/sriramsv/custom_component_splitwise", 5 | "dependencies": [], 6 | "codeowners": ["sriramsv"], 7 | "requirements": ["splitwise==2.4.0"], 8 | "iot_class": "cloud_polling", 9 | "version": "0.1.1" 10 | } 11 | -------------------------------------------------------------------------------- /custom_components/splitwise/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | 3 | from homeassistant.helpers.entity import Entity 4 | import homeassistant.helpers.config_validation as cv 5 | from homeassistant.components import http 6 | from homeassistant.helpers import network 7 | from homeassistant.core import callback 8 | import voluptuous as vol 9 | from .const import DOMAIN 10 | from homeassistant.components.sensor import PLATFORM_SCHEMA 11 | from homeassistant.const import CONF_CLIENT_SECRET, CONF_CLIENT_ID 12 | from splitwise import Splitwise 13 | import logging, os, json 14 | from homeassistant.helpers import network 15 | 16 | _TOKEN_FILE = "splitwise.conf" 17 | _LOGGER = logging.getLogger(__name__) 18 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 19 | { 20 | vol.Required(CONF_CLIENT_ID): cv.string, 21 | vol.Required(CONF_CLIENT_SECRET): cv.string, 22 | } 23 | ) 24 | DATA_CALLBACK = "splitwise-callback" 25 | AUTH_CALLBACK_PATH = "/api/splitwise/callback" 26 | SENSOR_NAME = "Splitwise" 27 | 28 | 29 | class AuthenticationFailedException(Exception): 30 | pass 31 | 32 | 33 | def format_name(str): 34 | return str.lower().replace(" ", "_").strip("_").replace("'", "_").replace("-", "_") 35 | 36 | 37 | def get_url(hass): 38 | """Gets the required Home-Assistant URL for validation. 39 | Args: 40 | hass: Hass instance. 41 | Returns: 42 | Home-Assistant URL. 43 | """ 44 | if network: 45 | try: 46 | return network.get_url( 47 | hass, 48 | allow_external=True, 49 | allow_internal=True, 50 | allow_ip=False, 51 | prefer_external=True, 52 | require_ssl=True, 53 | ) 54 | except network.NoURLAvailableError: 55 | _LOGGER.debug("Hass version does not have get_url helper, using fall back.") 56 | 57 | base_url = hass.config.api.base_url 58 | if base_url: 59 | return base_url 60 | 61 | raise ValueError("Unable to obtain HASS url.") 62 | 63 | 64 | def setup(hass, config): 65 | """Your controller/hub specific code.""" 66 | # Data that you want to share with your platforms 67 | 68 | hass.helpers.discovery.load_platform("sensor", DOMAIN, {}, config) 69 | 70 | return True 71 | 72 | 73 | def setup_platform(hass, config, add_entities, discovery_info=None): 74 | client_id = config[CONF_CLIENT_ID] 75 | client_secret = config[CONF_CLIENT_SECRET] 76 | add_entities([SplitwiseSensor(hass, client_id, client_secret)]) 77 | 78 | 79 | class SplitwiseApi: 80 | def __init__(self, sensor, client_id, client_secret): 81 | self.sensor = sensor 82 | self.secret = None 83 | self.isAuthSuccess = False 84 | self.client_id = client_id 85 | self.client_secret = client_secret 86 | self.redirect_uri = None 87 | try: 88 | self.splitwise = Splitwise( 89 | consumer_key=client_id, consumer_secret=client_secret 90 | ) 91 | except Exception as e: 92 | raise Exception("Cannot initialize Splitwise Client:{}".format(e)) 93 | 94 | @property 95 | def token_file_name(self): 96 | # From config/custom_components/splitwise to config/ 97 | base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 98 | token_file = _TOKEN_FILE 99 | full_token_filepath = os.path.join(base_path, token_file) 100 | return full_token_filepath 101 | 102 | def get_access_token_from_file(self): 103 | if not os.path.isfile(self.token_file_name): 104 | self.get_credentials() 105 | return 106 | 107 | with open(self.token_file_name, "r") as token_file: 108 | token_data = json.loads(token_file.read()) or {} 109 | 110 | if "access_token" not in token_data: 111 | code = token_data.get("code") 112 | if code: 113 | self.get_access_token(code) 114 | else: 115 | self.isAuthSuccess = True 116 | self.splitwise.setOAuth2AccessToken(token_data) 117 | 118 | @property 119 | def is_authenticated(self): 120 | return self.isAuthSuccess 121 | 122 | def get_access_token(self, code): 123 | with open(self.token_file_name, "r+") as token_file: 124 | token_data = json.loads(token_file.read()) or {} 125 | access_token = token_data.get("access_token") 126 | if not access_token: 127 | access_token = self.splitwise.getOAuth2AccessToken( 128 | code, self.get_redirect_uri() 129 | ) 130 | with open(self.token_file_name, "w+") as token_file: 131 | token_file.write(json.dumps(access_token)) 132 | self.isAuthSuccess = True 133 | self.splitwise.setOAuth2AccessToken(access_token) 134 | 135 | def get_credentials(self): 136 | url, state = self.splitwise.getOAuth2AuthorizeURL(self.get_redirect_uri()) 137 | self.sensor.create_oauth_view(url) 138 | 139 | def get_redirect_uri(self): 140 | return "{}{}".format(self.sensor.hass_url, AUTH_CALLBACK_PATH) 141 | 142 | 143 | class SplitwiseSensor(Entity): 144 | def __init__(self, hass, client_id, client_secret): 145 | """Initialize the sensor.""" 146 | self.hass = hass 147 | self.hass_url = get_url(hass) 148 | self.api = SplitwiseApi(self, client_id, client_secret) 149 | self._state = None 150 | self._user_id = None 151 | self.currency = None 152 | self._first_name = None 153 | self._last_name = None 154 | self._friends_list = {} 155 | self._group_map = {} 156 | self._id_map = {} 157 | self.api.get_access_token_from_file() 158 | 159 | @property 160 | def name(self): 161 | """Return the name of the sensor.""" 162 | return "Splitwise Sensor" 163 | 164 | @property 165 | def state(self): 166 | """Return the state of the sensor.""" 167 | return self._state 168 | 169 | @property 170 | def unit_of_measurement(self): 171 | """Return the unit of measurement.""" 172 | return self.currency 173 | 174 | @property 175 | def extra_state_attributes(self): 176 | m = {} 177 | if self._user_id: 178 | m["id"] = self._user_id 179 | if self._first_name: 180 | m["first_name"] = self._first_name 181 | if self._last_name: 182 | m["last_name"] = self._last_name 183 | 184 | if len(self._friends_list) > 0: 185 | for k, v in self._friends_list.items(): 186 | if v["total_balance"] != 0.0: 187 | m[format_name(k)] = v["total_balance"] 188 | 189 | if len(self._group_map) > 0: 190 | for k, v in self._group_map.items(): 191 | if v != 0.0: 192 | m[format_name(k)] = v 193 | return m 194 | 195 | def update(self): 196 | """Fetch new state data for the sensor. 197 | 198 | This is the only method that should fetch new data for Home Assistant. 199 | """ 200 | self.api.get_access_token_from_file() 201 | if not self.api.is_authenticated: 202 | raise AuthenticationFailedException("error fetching authentication token") 203 | self.hass.components.persistent_notification.dismiss( 204 | notification_id=f"splitwise_setup_{SENSOR_NAME}" 205 | ) 206 | user = self.api.splitwise.getCurrentUser() 207 | self._user_id = user.getId() 208 | self.currency = user.getDefaultCurrency() 209 | self._first_name = user.getFirstName().title().lower() 210 | self._id_map[self._user_id] = self._first_name 211 | self._last_name = user.getLastName().title().lower() 212 | friends = self.api.splitwise.getFriends() 213 | all_balance = 0.0 214 | 215 | for f in friends: 216 | name = f.getFirstName().title().lower() 217 | id = f.getId() 218 | total_balance = 0.0 219 | for balance in f.getBalances(): 220 | amount = float(balance.getAmount()) 221 | total_balance += amount 222 | self._friends_list[name] = { 223 | "total_balance": total_balance, 224 | "id": id, 225 | } 226 | self._id_map[id] = name 227 | all_balance += total_balance 228 | self._state = all_balance 229 | self.get_group_data() 230 | self.emit_notifications(self.api.splitwise.getNotifications()) 231 | 232 | def emit_notifications(self, notifications): 233 | for n in notifications: 234 | self.hass.bus.fire("splitwise_notification_event_" + str(n.getType()), { 235 | "id": n.getId(), 236 | "type": n.getType(), 237 | "image_url": n.getImageUrl(), 238 | "content": n.getContent(), 239 | "image_shape": n.getImageShape(), 240 | "created_at": n.getCreatedAt(), 241 | "created_by": n.getCreatedBy(), 242 | "source": { "id": n.source.getId(), "type": n.source.getType(), "url": n.source.getUrl() } 243 | }, origin="REMOTE") 244 | 245 | def get_group_data(self): 246 | groups = self.api.splitwise.getGroups() 247 | for g in groups: 248 | amount = 0.0 249 | for d in g.getOriginalDebts(): 250 | if self._id_map[d.getToUser()] == self._first_name: 251 | amount -= float(d.getAmount()) 252 | elif self._id_map[d.getFromUser()] == self._first_name: 253 | amount += float(d.getAmount()) 254 | self._group_map[g.getName()] = amount 255 | 256 | def create_oauth_view(self, auth_url): 257 | try: 258 | self.hass.http.register_view( 259 | SplitwiseAuthCallbackView(self, self.api.token_file_name) 260 | ) 261 | except Exception as e: 262 | _LOGGER.error("Splitwise CallbackView Error {}".format(e)) 263 | return 264 | 265 | self.hass.components.persistent_notification.create( 266 | "In order to authorize Home-Assistant to view your Splitwise data, " 267 | "you must visit: " 268 | f'{auth_url}. Make ' 269 | f"sure that you have added {self.api.redirect_uri} to your " 270 | "Redirect URIs on Splitwise Developer portal.", 271 | title=SENSOR_NAME, 272 | notification_id=f"splitwise_setup_{SENSOR_NAME}", 273 | ) 274 | 275 | 276 | class SplitwiseAuthCallbackView(http.HomeAssistantView): 277 | """Web view that handles OAuth authentication and redirection flow.""" 278 | 279 | requires_auth = False 280 | url = AUTH_CALLBACK_PATH 281 | name = "api:splitwise:callback" 282 | 283 | def __init__(self, sensor, token_file): 284 | self.sensor = sensor 285 | self.token_file_name = token_file 286 | 287 | @callback 288 | async def get(self, request): # pylint: disable=no-self-use 289 | """Handle browser HTTP request.""" 290 | hass = request.app["hass"] 291 | params = request.query 292 | response = self.json_message("You can close this window now") 293 | 294 | code = params.get("code") 295 | code_data = {"code": code} 296 | with open(self.token_file_name, "w+") as token_file: 297 | token_file.write(json.dumps(code_data)) 298 | await hass.async_add_executor_job(self.sensor.update) 299 | return response 300 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Splitwise Sensor", 3 | "domains": ["sensor"], 4 | "country": ["US"], 5 | "version": "0.1.1", 6 | "render_readme": true 7 | } 8 | -------------------------------------------------------------------------------- /images/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/account.png -------------------------------------------------------------------------------- /images/auth-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/auth-url.png -------------------------------------------------------------------------------- /images/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/dash.png -------------------------------------------------------------------------------- /images/edit-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/edit-app.png -------------------------------------------------------------------------------- /images/oauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/oauth.png -------------------------------------------------------------------------------- /images/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/profile.png -------------------------------------------------------------------------------- /images/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sriramsv/custom_component_splitwise/5528e251dba496150eb8e7084669b49333f9a282/images/register.png --------------------------------------------------------------------------------