├── _config.yml ├── custom_components └── personalcapital │ ├── __init__.py │ ├── manifest.json │ └── sensor.py ├── resources.json ├── package.yaml ├── LICENSE └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /custom_components/personalcapital/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources.json: -------------------------------------------------------------------------------- 1 | [ 2 | "https://raw.githubusercontent.com/custom-components/sensor.personalcapital/master/custom_components/personalcapital/__init__.py", 3 | "https://raw.githubusercontent.com/custom-components/sensor.personalcapital/master/custom_components/personalcapital/manifest.json" 4 | ] 5 | -------------------------------------------------------------------------------- /custom_components/personalcapital/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "personalcapital", 3 | "name": "Personal Capital", 4 | "documentation": "https://github.com/custom-components/sensor.personalcapital/blob/master/README.md", 5 | "dependencies": [], 6 | "codeowners": ["@iantrich"], 7 | "requirements": ["personalcapital==1.0.1"], 8 | "version": "0.1.2" 9 | } 10 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | name: sensor.personalcapital 2 | description: 💵 Personal Capital component for Home Assistant 3 | type: component 4 | keywords: 5 | - sensor 6 | - personalcapital 7 | - finance 8 | - money 9 | - accounts 10 | author: 11 | name: Ian Richardson 12 | email: iantrich@gmail.com 13 | homepage: https://iantrich.github.io 14 | license: MIT 15 | files: 16 | - custom_components/personalcapital/sensor.py 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ian Richardson @iantrich 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sensor.personalcapital 2 | Personal Capital component for [Home Assistant](https://www.home-assistant.io/) 3 | 4 | [![GitHub Release][releases-shield]][releases] 5 | [![License][license-shield]](LICENSE.md) 6 | 7 | ![Project Maintenance][maintenance-shield] 8 | [![GitHub Activity][commits-shield]][commits] 9 | 10 | [![Discord][discord-shield]][discord] 11 | [![Community Forum][forum-shield]][forum] 12 | 13 | ## Support 14 | Hey dude! Help me out for a couple of :beers: or a :coffee:! 15 | 16 | [![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/zJtVxUAgH) 17 | 18 | To get started put all contents of `/custom_components/personalcapital/` here: 19 | `/custom_components/personalcapital/`. You can use this component with the custom [Personal Capital Lovelace card](https://github.com/custom-cards/pc-card). 20 | 21 | **Example configuration.yaml:** 22 | 23 | ```yaml 24 | sensor: 25 | platform: personalcapital 26 | email: iantrich@email.com 27 | password: 12345 28 | unit_of_measurement: CAD 29 | monitored_categories: 30 | - investment 31 | - cash 32 | ``` 33 | 34 | **Configuration variables:** 35 | 36 | key | description 37 | :--- | :--- 38 | **platform (Required)** | `personalcapital`` 39 | **email (Required)** | Email for personalcapital.com 40 | **password (Required)** | Password for personalcapital.com 41 | **unit_of_measurement (Optional)** | Unit of measurement for your accounts **Default** USD 42 | **monitored_categories (Optional)** | Banking categories to monitor. By default all categories are monitored. Options are `investment, mortgage, cash, other_asset, other_liability, credit, loan` 43 | *** 44 | 45 | **Note: You'll get a text message with your pin code to use on the frontend to configure** 46 | 47 | Due to how `custom_components` are loaded, it is normal to see a `ModuleNotFoundError` error on first boot after adding this, to resolve it, restart Home-Assistant. 48 | 49 | [commits-shield]: https://img.shields.io/github/commit-activity/y/custom-components/sensor.personalcapital.svg?style=for-the-badge 50 | [commits]: https://github.com/custom-components/sensor.personalcapital/commits/master 51 | [discord]: https://discord.gg/Qa5fW2R 52 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 53 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 54 | [forum]: https://community.home-assistant.io/t/lovelace-personal-capital-component-card/91463 55 | [license-shield]: https://img.shields.io/github/license/custom-components/sensor.personalcapital.svg?style=for-the-badge 56 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Ian%20Richardson%20%40iantrich-blue.svg?style=for-the-badge 57 | [releases-shield]: https://img.shields.io/github/release/custom-components/sensor.personalcapital.svg?style=for-the-badge 58 | [releases]: https://github.com/custom-components/sensor.personalcapital/releases 59 | -------------------------------------------------------------------------------- /custom_components/personalcapital/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Personal Capital sensors. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://github.com/custom-components/sensor.personalcapital 6 | """ 7 | 8 | import logging 9 | import voluptuous as vol 10 | import json 11 | import time 12 | from datetime import timedelta 13 | from homeassistant.helpers.entity import Entity 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.components.sensor import (PLATFORM_SCHEMA) 16 | from homeassistant.util import Throttle 17 | 18 | __version__ = '0.1.1' 19 | 20 | REQUIREMENTS = ['personalcapital==1.0.1'] 21 | 22 | CONF_EMAIL = 'email' 23 | CONF_PASSWORD = 'password' 24 | CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' 25 | CONF_CATEGORIES = 'monitored_categories' 26 | 27 | SESSION_FILE = '.pc-session' 28 | DATA_PERSONAL_CAPITAL = 'personalcapital_cache' 29 | 30 | ATTR_NETWORTH = 'networth' 31 | ATTR_ASSETS = 'assets' 32 | ATTR_LIABILITIES = 'liabilities' 33 | ATTR_INVESTMENT = 'investment' 34 | ATTR_MORTGAGE = 'mortgage' 35 | ATTR_CASH = 'cash' 36 | ATTR_OTHER_ASSET = 'other_asset' 37 | ATTR_OTHER_LIABILITY = 'other_liability' 38 | ATTR_CREDIT = 'credit' 39 | ATTR_LOAN = 'loan' 40 | 41 | SCAN_INTERVAL = timedelta(minutes=5) 42 | MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) 43 | 44 | SENSOR_TYPES = { 45 | ATTR_INVESTMENT: ['INVESTMENT', '', 'investmentAccountsTotal', 'Investment', False], 46 | ATTR_MORTGAGE: ['MORTGAGE', '', 'mortgageAccountsTotal', 'Mortgage', True], 47 | ATTR_CASH: ['BANK', 'Cash', 'cashAccountsTotal', 'Cash', False], 48 | ATTR_OTHER_ASSET: ['OTHER_ASSETS', '', 'otherAssetAccountsTotal', 'Other Asset', False], 49 | ATTR_OTHER_LIABILITY: ['OTHER_LIABILITIES', '', 'otherLiabilitiesAccountsTotal', 'Other Liability', True], 50 | ATTR_CREDIT: ['CREDIT_CARD', '', 'creditCardAccountsTotal', 'Credit', True], 51 | ATTR_LOAN: ['LOAN', '', 'loanAccountsTotal', 'Loan', True], 52 | } 53 | 54 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 55 | vol.Required(CONF_EMAIL): cv.string, 56 | vol.Required(CONF_PASSWORD): cv.string, 57 | vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='USD'): cv.string, 58 | vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), 59 | }) 60 | 61 | _CONFIGURING = {} 62 | _LOGGER = logging.getLogger(__name__) 63 | 64 | 65 | def request_app_setup(hass, config, pc, add_devices, discovery_info=None): 66 | """Request configuration steps from the user.""" 67 | from personalcapital import PersonalCapital, RequireTwoFactorException, TwoFactorVerificationModeEnum 68 | configurator = hass.components.configurator 69 | 70 | def personalcapital_configuration_callback(data): 71 | """Run when the configuration callback is called.""" 72 | from personalcapital import PersonalCapital, RequireTwoFactorException, TwoFactorVerificationModeEnum 73 | pc.two_factor_authenticate(TwoFactorVerificationModeEnum.SMS, data.get('verification_code')) 74 | result = pc.authenticate_password(config.get(CONF_PASSWORD)) 75 | 76 | if result == RequireTwoFactorException: 77 | configurator.notify_errors(_CONFIGURING['personalcapital'], "Invalid verification code") 78 | else: 79 | save_session(hass, pc.get_session()) 80 | continue_setup_platform(hass, config, pc, add_devices, discovery_info) 81 | 82 | if 'personalcapital' not in _CONFIGURING: 83 | try: 84 | pc.login(config.get(CONF_EMAIL), config.get(CONF_PASSWORD)) 85 | except RequireTwoFactorException: 86 | pc.two_factor_challenge(TwoFactorVerificationModeEnum.SMS) 87 | 88 | _CONFIGURING['personalcapital'] = configurator.request_config( 89 | 'Personal Capital', 90 | personalcapital_configuration_callback, 91 | description="Verification code sent to phone", 92 | submit_caption='Verify', 93 | fields=[{ 94 | 'id': 'verification_code', 95 | 'name': "Verification code", 96 | 'type': 'string'}] 97 | ) 98 | 99 | 100 | def load_session(hass): 101 | try: 102 | with open(hass.config.path(SESSION_FILE)) as data_file: 103 | cookies = {} 104 | try: 105 | cookies = json.load(data_file) 106 | except ValueError as err: 107 | return {} 108 | return cookies 109 | except IOError as err: 110 | return {} 111 | 112 | 113 | def save_session(hass, session): 114 | with open(hass.config.path(SESSION_FILE), 'w') as data_file: 115 | data_file.write(json.dumps(session)) 116 | 117 | 118 | def setup_platform(hass, config, add_devices, discovery_info=None): 119 | """Set up the Personal Capital component.""" 120 | from personalcapital import PersonalCapital, RequireTwoFactorException, TwoFactorVerificationModeEnum 121 | pc = PersonalCapital() 122 | session = load_session(hass) 123 | 124 | if len(session) > 0: 125 | pc.set_session(session) 126 | 127 | try: 128 | pc.login(config.get(CONF_EMAIL), config.get(CONF_PASSWORD)) 129 | continue_setup_platform(hass, config, pc, add_devices, discovery_info) 130 | except RequireTwoFactorException: 131 | request_app_setup(hass, config, pc, add_devices, discovery_info) 132 | else: 133 | request_app_setup(hass, config, pc, add_devices, discovery_info) 134 | 135 | 136 | def continue_setup_platform(hass, config, pc, add_devices, discovery_info=None): 137 | """Set up the Personal Capital component.""" 138 | if "personalcapital" in _CONFIGURING: 139 | hass.components.configurator.request_done(_CONFIGURING.pop("personalcapital")) 140 | 141 | rest_pc = PersonalCapitalAccountData(pc, config) 142 | uom = config[CONF_UNIT_OF_MEASUREMENT] 143 | sensors = [] 144 | categories = config[CONF_CATEGORIES] if len(config[CONF_CATEGORIES]) > 0 else SENSOR_TYPES.keys() 145 | sensors.append(PersonalCapitalNetWorthSensor(rest_pc, config[CONF_UNIT_OF_MEASUREMENT])) 146 | for category in categories: 147 | sensors.append(PersonalCapitalCategorySensor(hass, rest_pc, uom, category)) 148 | add_devices(sensors, True) 149 | 150 | 151 | class PersonalCapitalNetWorthSensor(Entity): 152 | """Representation of a personalcapital.com net worth sensor.""" 153 | 154 | def __init__(self, rest, unit_of_measurement): 155 | """Initialize the sensor.""" 156 | self._rest = rest 157 | self._unit_of_measurement = unit_of_measurement 158 | self._state = None 159 | self._assets = None 160 | self._liabilities = None 161 | self.update() 162 | 163 | def update(self): 164 | """Get the latest state of the sensor.""" 165 | self._rest.update() 166 | data = self._rest.data.json()['spData'] 167 | self._state = data.get('networth', 0.0) 168 | self._assets = data.get('assets', 0.0) 169 | self._liabilities = format_balance(True, data.get('liabilities', 0.0)) 170 | 171 | @property 172 | def name(self): 173 | """Return the name of the sensor.""" 174 | return 'PC Networth' 175 | 176 | @property 177 | def state(self): 178 | """Return the state of the sensor.""" 179 | return self._state 180 | 181 | @property 182 | def unit_of_measurement(self): 183 | """Return the unit of measure this sensor expresses itself in.""" 184 | return self._unit_of_measurement 185 | 186 | @property 187 | def icon(self): 188 | """Return the icon to use in the frontend.""" 189 | return 'mdi:coin' 190 | 191 | @property 192 | def device_state_attributes(self): 193 | """Return the state attributes of the sensor.""" 194 | attributes = { 195 | ATTR_ASSETS: self._assets, 196 | ATTR_LIABILITIES: self._liabilities 197 | } 198 | return attributes 199 | 200 | 201 | class PersonalCapitalCategorySensor(Entity): 202 | """Representation of a personalcapital.com sensor.""" 203 | 204 | def __init__(self, hass, rest, unit_of_measurement, sensor_type): 205 | """Initialize the sensor.""" 206 | self.hass = hass 207 | self._rest = rest 208 | self._productType = SENSOR_TYPES[sensor_type][0] 209 | self._accountType = SENSOR_TYPES[sensor_type][1] 210 | self._balanceName = SENSOR_TYPES[sensor_type][2] 211 | self._name = f'PC {SENSOR_TYPES[sensor_type][3]}' 212 | self._inverse_sign = SENSOR_TYPES[sensor_type][4] 213 | self._state = None 214 | self._unit_of_measurement = unit_of_measurement 215 | 216 | def update(self): 217 | """Get the latest state of the sensor.""" 218 | self._rest.update() 219 | data = self._rest.data.json()['spData'] 220 | self._state = format_balance(self._inverse_sign, data.get(self._balanceName, 0.0)) 221 | accounts = data.get('accounts') 222 | self.hass.data[self._productType] = {'accounts': []} 223 | 224 | for account in accounts: 225 | if ((self._productType == account.get('productType')) or (self._accountType == account.get('accountType', ''))) and account.get('closeDate', '') == '': 226 | self.hass.data[self._productType].get('accounts').append({ 227 | "name": account.get('name', ''), 228 | "firm_name": account.get('firmName', ''), 229 | "logo": account.get('logoPath', ''), 230 | "balance": format_balance(self._inverse_sign, account.get('balance', 0.0)), 231 | "account_type": account.get('accountType', ''), 232 | "url": account.get('homeUrl', ''), 233 | "currency": account.get('currency', ''), 234 | "refreshed": how_long_ago(account.get('lastRefreshed', 0)) + ' ago', 235 | }) 236 | 237 | @property 238 | def name(self): 239 | """Return the name of the sensor.""" 240 | return self._name 241 | 242 | @property 243 | def state(self): 244 | """Return the state of the sensor.""" 245 | return self._state 246 | 247 | @property 248 | def unit_of_measurement(self): 249 | """Return the unit of measurement this sensor expresses itself in.""" 250 | return self._unit_of_measurement 251 | 252 | @property 253 | def icon(self): 254 | """Return the icon to use in the frontend.""" 255 | return 'mdi:coin' 256 | 257 | @property 258 | def device_state_attributes(self): 259 | """Return the state attributes of the sensor.""" 260 | return self.hass.data[self._productType] 261 | 262 | 263 | class PersonalCapitalAccountData(object): 264 | """Get data from personalcapital.com""" 265 | 266 | def __init__(self, pc, config): 267 | self._pc = pc 268 | self.data = None 269 | self._config = config 270 | 271 | @Throttle(MIN_TIME_BETWEEN_UPDATES) 272 | def update(self): 273 | """Get latest data from personal capital""" 274 | self.data = self._pc.fetch('/newaccount/getAccounts') 275 | 276 | if not self.data or not self.data.json()['spHeader']['success']: 277 | self._pc.login(self._config[CONF_EMAIL], self._config[CONF_PASSWORD]) 278 | self.data = self._pc.fetch('/newaccount/getAccounts') 279 | 280 | 281 | def how_long_ago(last_epoch): 282 | a = last_epoch 283 | b = time.time() 284 | c = b - a 285 | days = c // 86400 286 | hours = c // 3600 % 24 287 | minutes = c // 60 % 60 288 | 289 | if days > 0: 290 | return str(round(days)) + ' days' 291 | if hours > 0: 292 | return str(round(hours)) + ' hours' 293 | return str(round(minutes)) + ' minutes' 294 | 295 | 296 | def format_balance(inverse_sign, balance): 297 | return -1.0 * balance if inverse_sign is True else balance 298 | --------------------------------------------------------------------------------