├── .gitignore ├── .gitattributes ├── custom_components └── google_maps │ ├── __init__.py │ ├── manifest.json │ └── device_tracker.py ├── requirements.txt ├── deps └── lib │ └── python3.6 │ └── site-packages │ └── gmapslocsharing │ ├── core │ ├── config.conf │ ├── config.py │ ├── browser.py │ └── location.py │ └── __init__.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | **/dev 3 | *__pycache__* 4 | .remote-sync* 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Ignore all differences in line endings 2 | * -crlf 3 | -------------------------------------------------------------------------------- /custom_components/google_maps/__init__.py: -------------------------------------------------------------------------------- 1 | """Google Maps Location Sharing by shr00mie.""" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | chromedriver-binary==77.0.3865.40.0 2 | selenium==3.141.0 3 | selenium-wire==1.0.9 4 | geohash2==1.1 5 | -------------------------------------------------------------------------------- /custom_components/google_maps/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "google_maps", 3 | "name": "Google Maps", 4 | "documentation": "https://github.com/shr00mie/gmapslocsharing", 5 | "dependencies": [], 6 | "codeowners": ["shr00mie"], 7 | "requirements": ["selenium==3.141.0", 8 | "selenium-wire==1.0.9", 9 | "chromedriver-binary==77.0.3865.40.0", 10 | "geohash2==1.1"] 11 | } 12 | -------------------------------------------------------------------------------- /deps/lib/python3.6/site-packages/gmapslocsharing/core/config.conf: -------------------------------------------------------------------------------- 1 | [urls] 2 | cookie_check = 'https://google.com' 3 | login_start = 'https://accounts.google.com' 4 | login_success = 'https://myaccount.google.com/?utm_source=sign_in_no_continue&pli=1' 5 | 6 | [location] 7 | country = 'US' 8 | 9 | [requests] 10 | get = 'https://www.google.com/maps/@41.2598125,-102.9889142,4.6z?authuser' 11 | path = 'https://www.google.com/maps/preview/locationsharing/read?authuser' 12 | 13 | [account] 14 | 15 | [paths] 16 | debug_url = 'debug/urls_debug' 17 | debug_ss = 'debug/screenshots' 18 | debug_cookie = 'debug/cookie_debug' 19 | chrome_cache = ['chrome/Default/Cache', 'chrome/Default/Code Cache'] 20 | chrome_cookies = ['chrome/Default/Cookies', 'chrome/Default/Cookies-journal'] 21 | 22 | [prefs] 23 | DefaultJavaScriptSetting = 2 24 | DefaultGeolocationSetting = 2 25 | EnableMediaRouter = False 26 | PasswordManagerEnabled = False 27 | PrintingEnabled = False 28 | CloudPrintProxyEnabled = False 29 | SafeBrowsingExtendedReportingEnabled = False 30 | AutofillAddressEnabled = False 31 | BrowserSignin = 0 32 | BuiltInDnsClientEnabled = False 33 | CommandLineFlagSecurityWarningsEnabled = False 34 | MetricsReportingEnabled = False 35 | NetworkPredictionOptions = 2 36 | SavingBrowserHistoryDisabled = True 37 | SearchSuggestEnabled = False 38 | SpellCheckServiceEnabled = False 39 | SyncDisabled = True 40 | TranslateEnabled = False 41 | UrlKeyedAnonymizedDataCollectionEnabled = False 42 | -------------------------------------------------------------------------------- /deps/lib/python3.6/site-packages/gmapslocsharing/core/config.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser, ExtendedInterpolation 2 | from pathlib import Path as p 3 | import logging 4 | import ast 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | class Config: 9 | 10 | class __Config: 11 | 12 | def __init__(self, config_path, debug): 13 | 14 | log.debug('Initializing Config module.') 15 | self.path = config_path 16 | self.debug = debug 17 | self.config = ConfigParser(delimiters=('='), interpolation=ExtendedInterpolation()) 18 | self.startup() 19 | 20 | def startup(self): 21 | 22 | hassio = '/srv/homeassistant/lib/python3.6/site-packages/gmapslocsharing/core/config.conf' 23 | docker = '/config/deps/lib/python3.7/site-packages/gmapslocsharing/core/config.conf' 24 | 25 | if p(hassio).exists(): 26 | log.info('Loading hass.io config path.') 27 | log.debug('Config Path: {}'.format(hassio)) 28 | self.config.read(hassio) 29 | elif p(docker).exists(): 30 | log.info('Loading docker config path.') 31 | log.debug('Config Path: {}'.format(docker)) 32 | self.config.read(docker) 33 | else: 34 | suffix = '/site-packages/gmapslocsharing/core/config.conf' 35 | log.info('Loading manual install config path.') 36 | c = ''.join([_.as_posix() for _ in (self.path / 'deps/lib').glob('python*')]) 37 | c += suffix 38 | log.debug('Config Path: {}'.format(c)) 39 | self.config.read(c) 40 | 41 | def get(self, section:str, key:str): 42 | 43 | c = self.config.get(section, key, raw=True) 44 | 45 | try: 46 | return ast.literal_eval(c) 47 | except: 48 | return c 49 | 50 | def set(self, section:str, key:str, value): 51 | """ 52 | input: section, key, value 53 | """ 54 | 55 | if not self.config.has_section(section): 56 | self.config.add_section(section) 57 | if isinstance(value, list): 58 | self.config.set(section, key, str(value)) 59 | else: 60 | self.config.set(section, key, value) 61 | 62 | @property 63 | def email(self): 64 | return self.get('account', 'email') 65 | 66 | @property 67 | def password(self): 68 | return self.get('account', 'password') 69 | 70 | @property 71 | def path_chrome(self): 72 | return self.path / 'chrome' 73 | 74 | @property 75 | def path_debug(self): 76 | return self.path / 'debug' 77 | 78 | @property 79 | def path_debug_core(self): 80 | return self.path / 'debug/core' 81 | 82 | @property 83 | def path_debug_browser(self): 84 | return self.path / 'debug/browser' 85 | 86 | @property 87 | def path_debug_location(self): 88 | return self.path / 'debug/location' 89 | 90 | @property 91 | def path_debug_backup(self): 92 | return self.path / 'debug/backup' 93 | 94 | @property 95 | def path_chrome_nuke(self): 96 | chrome_cookie_paths = self.get('paths', 'chrome_cookies') 97 | chrome_cache_paths = self.get('paths', 'chrome_cache') 98 | return [self.path / path for path in (chrome_cookie_paths + chrome_cache_paths)] 99 | 100 | @property 101 | def requests_get(self): 102 | return self.get('requests', 'get') 103 | 104 | @property 105 | def requests_path(self): 106 | return self.get('requests', 'path') 107 | 108 | @property 109 | def login_start(self): 110 | return self.get('urls', 'login_start') 111 | 112 | @property 113 | def login_success(self): 114 | return self.get('urls', 'login_success') 115 | 116 | @property 117 | def cookie_check(self): 118 | return self.get('urls', 'cookie_check') 119 | 120 | instance = None 121 | 122 | def __init__(self, config_path=None, debug=False): 123 | 124 | if not Config.instance: 125 | Config.instance = Config.__Config(config_path, debug) 126 | 127 | def __getattr__(self, name): 128 | 129 | return getattr(self.instance, name) 130 | -------------------------------------------------------------------------------- /deps/lib/python3.6/site-packages/gmapslocsharing/__init__.py: -------------------------------------------------------------------------------- 1 | from .core.location import Location 2 | from .core.browser import Browser 3 | from .core.config import Config 4 | from datetime import datetime 5 | from pathlib import Path as p 6 | from shutil import move 7 | import logging 8 | import re 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | class GoogleMaps: 13 | 14 | def __init__(self, email, password, config_path, debug): 15 | 16 | log.debug('Initializing GoogleMaps module.') 17 | self.path = p(config_path) 18 | self.config = Config(self.path, debug) 19 | self.startup(email, password) 20 | self.browser = Browser() 21 | self.location = Location() 22 | self.people = None 23 | self.update() 24 | 25 | def startup(self, email, password): 26 | 27 | log.info('Initiating System check.') 28 | 29 | log.debug('Checking email.') 30 | if self.check_email(email): 31 | log.debug('Email OK.') 32 | self.config.set('account', 'email', email) 33 | else: 34 | log.debug('Email validation error. Exiting.') 35 | 36 | log.debug('Checking password.') 37 | if self.check_password(password): 38 | log.debug('Password OK.') 39 | self.config.set('account', 'password', password) 40 | else: 41 | log.debug('Password validation failed or password too short. Exiting.') 42 | 43 | log.debug('Checking core folders.') 44 | if self.check_folders(): 45 | log.debug('Core & debug folders OK.') 46 | else: 47 | log.debug('Error creating core/debug folders. Exiting.') 48 | 49 | def check_email(self, email:str) -> bool: 50 | 51 | return True if re.match('^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email) else False 52 | 53 | def check_password(self, password:str) -> bool: 54 | 55 | # TODO: Figure out a better way to check passwords. 56 | # Maybe kick something back if it's a stupid short password. 57 | if all([isinstance(password, str), len(password) > 8]): 58 | return True 59 | return False 60 | 61 | def check_folders(self) -> bool: 62 | 63 | locations = [ 64 | self.config.path_chrome, 65 | self.config.path_debug, 66 | ] 67 | try: 68 | for location in locations: 69 | if all([location.exists(), location.is_dir()]): 70 | log.debug('{} exists.'.format(location.as_posix())) 71 | else: 72 | log.debug('Creating {}'.format(location.as_posix())) 73 | location.mkdir(mode=0o770, parents=True) 74 | self.debug_backup() 75 | return True 76 | except: 77 | return False 78 | 79 | def debug_backup(self): 80 | 81 | backup_dt = datetime.now().strftime('%Y.%m.%d_%H.%M') 82 | debug_backup_sub = self.config.path_debug_backup / backup_dt 83 | 84 | dfl = [dfl for dfl in self.config.path_debug.iterdir() if dfl.stem != 'backup'] 85 | 86 | if len(dfl) == 0: 87 | if not self.config.path_debug_backup.exists(): 88 | log.debug('Debug backup path does not exist. Creating.') 89 | self.config.path_debug_backup.mkdir(mode=0o770) 90 | elif len(dfl) >= 1: 91 | log.debug('Previous debug content found. Moving to: {}'.format(debug_backup_sub)) 92 | debug_backup_sub.mkdir(mode=0o770, parents=True) 93 | for fof in dfl: 94 | move(fof.as_posix(), debug_backup_sub.as_posix()) 95 | 96 | def debug(self, source, data): 97 | 98 | if self.config.debug: 99 | path = self.config.path_debug_core 100 | if not path.exists(): 101 | path.mkdir(mode=0o770, parents=True) 102 | timestamp = datetime.now().strftime('%Y-%m-%d - %H:%M:%S') 103 | if source == 'update': 104 | update_path = path / source 105 | with update_path.open('a+') as f: 106 | f.write('{}\n'.format(data)) 107 | 108 | def update(self): 109 | 110 | raw_data = self.browser.update() 111 | if raw_data: 112 | self.debug('update', raw_data) 113 | raw_data = raw_data.decode('utf-8') 114 | if any(['DOCTYPE' in raw_data, raw_data == None]): 115 | log.debug('Request error. Ignoring.') 116 | elif all([isinstance(raw_data, str), raw_data.startswith(')]}\'\n[[[[')]): 117 | raw_data = raw_data.split('[[')[2:] 118 | self.people = self.location.update(raw_data) 119 | else: 120 | log.error('Update failed.') 121 | -------------------------------------------------------------------------------- /custom_components/google_maps/device_tracker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Google Maps location sharing. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://home-assistant.io/components/device_tracker.google_maps/ 6 | """ 7 | from datetime import timedelta, datetime, timezone 8 | from gmapslocsharing import GoogleMaps 9 | import voluptuous as vol 10 | import geohash2 11 | import logging 12 | 13 | from homeassistant.components.device_tracker import ( 14 | PLATFORM_SCHEMA, 15 | SOURCE_TYPE_GPS, 16 | DeviceScanner 17 | ) 18 | 19 | from homeassistant.const import ( 20 | ATTR_ID, 21 | CONF_PASSWORD, 22 | CONF_USERNAME, 23 | ATTR_BATTERY_CHARGING, 24 | ATTR_BATTERY_LEVEL 25 | ) 26 | 27 | import homeassistant.helpers.config_validation as cv 28 | 29 | from homeassistant.helpers.event import track_time_interval 30 | 31 | from homeassistant.helpers.typing import ConfigType 32 | 33 | from homeassistant.util import slugify, dt as dt_util 34 | 35 | log = logging.getLogger(__name__) 36 | 37 | ATTR_ADDRESS = 'address' 38 | ATTR_FULL_NAME = 'full_name' 39 | ATTR_LAST_SEEN = 'last_seen' 40 | ATTR_FIRST_NAME = 'first_name' 41 | ATTR_GEOHASH = 'geohash' 42 | 43 | CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' 44 | CONF_DEBUG = 'debug' 45 | 46 | MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) 47 | 48 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 49 | vol.Required(CONF_PASSWORD): cv.string, 50 | vol.Required(CONF_USERNAME): cv.string, 51 | vol.Optional(CONF_MAX_GPS_ACCURACY, default=500): vol.Coerce(float), 52 | vol.Optional(CONF_DEBUG, default=False): vol.Coerce(bool) 53 | }) 54 | 55 | def setup_scanner(hass, config: ConfigType, see, discovery_info=None): 56 | """Set up the Google Maps Location sharing scanner.""" 57 | scanner = GoogleMapsScanner(hass, config, see) 58 | return True 59 | 60 | class GoogleMapsScanner(DeviceScanner): 61 | """Representation of an Google Maps location sharing account.""" 62 | 63 | def __init__(self, hass, config: ConfigType, see) -> None: 64 | """Initialize the scanner.""" 65 | 66 | self.see = see 67 | self.username = config[CONF_USERNAME] 68 | self.password = config[CONF_PASSWORD] 69 | self.debug = config[CONF_DEBUG] 70 | self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] 71 | 72 | self.service = GoogleMaps( 73 | self.username, 74 | self.password, 75 | hass.config.path(), 76 | self.debug 77 | ) 78 | track_time_interval( 79 | hass, 80 | self._update_info, 81 | MIN_TIME_BETWEEN_SCANS 82 | ) 83 | 84 | def format_datetime(self, input): 85 | 86 | dt = datetime.fromtimestamp(input) 87 | return dt.strftime('%Y-%m-%d %H:%M:%S') 88 | 89 | def _update_info(self, now=None): 90 | 91 | self.service.update() 92 | 93 | for person in self.service.people: 94 | 95 | try: 96 | dev_id = person.id 97 | except TypeError: 98 | log.warning("No location(s) shared with this account") 99 | return 100 | 101 | if self.max_gps_accuracy is not None and person.accuracy > self.max_gps_accuracy: 102 | log.info('Ignoring {} update because expected GPS accuracy {} is not met: {}' 103 | .format( 104 | person.first_name, 105 | self.max_gps_accuracy, 106 | person.accuracy 107 | ) 108 | ) 109 | continue 110 | 111 | attrs = { 112 | ATTR_ADDRESS: person.address, 113 | ATTR_FIRST_NAME: person.first_name, 114 | ATTR_FULL_NAME: person.full_name, 115 | ATTR_ID: person.id, 116 | ATTR_LAST_SEEN: self.format_datetime(person.last_seen), 117 | ATTR_GEOHASH: geohash2.encode(person.latitude, person.longitude, precision=12), 118 | ATTR_BATTERY_CHARGING: person.battery_charging, 119 | ATTR_BATTERY_LEVEL: person.battery_level 120 | } 121 | 122 | self.see( 123 | dev_id='{}_{}'.format(person.first_name, str(person.id)[-8:]), 124 | gps=(person.latitude, person.longitude), 125 | picture=person.picture_url, 126 | source_type=SOURCE_TYPE_GPS, 127 | host_name=person.first_name, 128 | gps_accuracy=person.accuracy, 129 | attributes=attrs 130 | ) 131 | -------------------------------------------------------------------------------- /deps/lib/python3.6/site-packages/gmapslocsharing/core/browser.py: -------------------------------------------------------------------------------- 1 | from selenium.webdriver.support import expected_conditions as ec 2 | from selenium.webdriver.support.ui import WebDriverWait 3 | from selenium.webdriver.chrome.options import Options 4 | from selenium.webdriver.common.keys import Keys 5 | from selenium.webdriver.common.by import By 6 | 7 | from seleniumwire import webdriver 8 | 9 | import chromedriver_binary 10 | 11 | from random import randrange 12 | from .config import Config 13 | import logging 14 | import shutil 15 | import sys 16 | import os 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | selenium_logger = logging.getLogger('seleniumwire') 21 | selenium_logger.setLevel(logging.ERROR) 22 | 23 | class Browser: 24 | 25 | def __init__(self): 26 | 27 | log.debug('Initializing Browser module.') 28 | self.config = Config() 29 | self.driver = self.browser_init() 30 | self.cookie_check() 31 | 32 | def browser_init(self): 33 | 34 | L = randrange(668,868,1) 35 | W = randrange(924,1124,1) 36 | 37 | try: 38 | log.debug('Setting up webdriver Chrome options.') 39 | chrome_options = Options() 40 | prefs = { 41 | 'DefaultJavaScriptSetting': 2, 42 | 'DefaultGeolocationSetting': 2, 43 | 'EnableMediaRouter': False, 44 | 'PasswordManagerEnabled': False, 45 | 'PrintingEnabled': False, 46 | 'CloudPrintProxyEnabled': False, 47 | 'SafeBrowsingExtendedReportingEnabled': False, 48 | 'AutofillAddressEnabled': False, 49 | 'BrowserSignin': 0, 50 | 'BuiltInDnsClientEnabled': False, 51 | 'CommandLineFlagSecurityWarningsEnabled': False, 52 | 'MetricsReportingEnabled': False, 53 | 'NetworkPredictionOptions': 2, 54 | 'SavingBrowserHistoryDisabled': True, 55 | 'SearchSuggestEnabled': False, 56 | 'SpellCheckServiceEnabled': False, 57 | 'SyncDisabled': True, 58 | 'TranslateEnabled': False, 59 | 'UrlKeyedAnonymizedDataCollectionEnabled': False 60 | } 61 | chrome_options.add_experimental_option('prefs', prefs) 62 | chrome_options.add_argument('--user-data-dir={}'.format(self.config.path_chrome)) 63 | chrome_options.add_argument('--window-size={}x{}'.format(L,W)) 64 | chrome_options.add_argument('--disable-plugin-discovery') 65 | chrome_options.add_argument('--disable-extensions') 66 | chrome_options.add_argument('--no-sandbox') 67 | chrome_options.headless = True 68 | return webdriver.Chrome(options=chrome_options) 69 | except Exception as e: 70 | log.debug('Browser init error: {}.'.format(e)) 71 | 72 | def browser_login(self): 73 | 74 | if self.driver == None: 75 | self.driver = self.browser_init() 76 | 77 | try: 78 | self.driver.get(self.config.login_start) 79 | except Exception as e: 80 | log.error('Error opening login url: {}.'.format(e)) 81 | 82 | self.debug('01_login') 83 | 84 | try: 85 | email = self.driver.find_element_by_css_selector('[type=email]') 86 | email.send_keys(self.config.email) 87 | email.send_keys(Keys.RETURN) 88 | except Exception as e: 89 | log.error('Error entering email: {}.'.format(e)) 90 | 91 | self.debug('02_email') 92 | 93 | try: 94 | password = self.driver.find_element_by_css_selector('[type=password]') 95 | password.send_keys(self.config.password) 96 | password.send_keys(Keys.RETURN) 97 | except Exception as e: 98 | log.error('Error entering password: {}.'.format(e)) 99 | 100 | self.debug('03_password') 101 | 102 | try: 103 | wait = WebDriverWait(self.driver, 15, poll_frequency=1) 104 | wait.until(ec.url_to_be(self.config.login_success)) 105 | except Exception as e: 106 | log.error('Error during 2FA process: {}.'.format(e)) 107 | 108 | self.debug('04_2FA') 109 | 110 | def cookie_check(self): 111 | 112 | self.driver.get(self.config.cookie_check) 113 | all_cookies = [cookie for cookie in self.driver.get_cookies()] 114 | all_cookie_names = [cookie['name'] for cookie in all_cookies] 115 | auth_cookies = [cookie for cookie in self.driver.get_cookies() if cookie['name'] in ['SID', 'HSID']] 116 | if len(auth_cookies) == 2: 117 | log.debug('Auth cookies exist. Checking expiry.') 118 | # TODO: figure out the best way to check for cookie 119 | # expiry and then nuke cookies & cache and perform login. 120 | elif all([len(all_cookie_names) == 2, '1P_JAR' in all_cookie_names, 'NID' in all_cookie_names]): 121 | log.debug('No auth cookies exist. Logging in.') 122 | self.browser_login() 123 | else: 124 | log.debug('Nuking cookies & cache.') 125 | self.nuke_cookies() 126 | log.debug('Creating new cookie.') 127 | 128 | def nuke_cookies(self): 129 | 130 | log.debug('Deleting all cookies.') 131 | self.driver.delete_all_cookies() 132 | log.debug('Quitting driver.') 133 | self.driver.quit() 134 | self.driver = None 135 | 136 | log.debug('Nuking cookies & cache.') 137 | for target in self.config.path_chrome_nuke: 138 | if target.is_dir(): 139 | shutil.rmtree(target, ignore_errors=False, onerror=None) 140 | elif target.is_file(): 141 | os.remove(target) 142 | 143 | log.debug('Reinitializing browser and logging in.') 144 | self.browser_login() 145 | 146 | def debug(self, step): 147 | 148 | if self.config.debug: 149 | 150 | path = self.config.path_debug_browser 151 | 152 | if not path.exists(): 153 | path.mkdir(mode=0o770, parents=True) 154 | 155 | url = path / 'urls' 156 | ss = path / 'screenshots' 157 | 158 | with url.open('a+') as f: 159 | f.write('\n\n{} - {}\n'.format(step, self.driver.current_url)) 160 | 161 | if not ss.exists(): 162 | ss.mkdir(mode=0o770, parents=True) 163 | ss_path = ss / '{}.png'.format(step) 164 | self.driver.save_screenshot(ss_path.as_posix()) 165 | 166 | def update(self): 167 | 168 | log.debug('Querying google maps location sharing data.') 169 | try: 170 | del self.driver.requests 171 | driver_get = '{}={}'.format(self.config.requests_get, self.config.email) 172 | driver_wait = self.config.requests_path 173 | self.driver.get(driver_get) 174 | self.driver.wait_for_request(driver_wait) 175 | requests = [request for request in self.driver.requests] 176 | for request in requests: 177 | if self.config.requests_path in request.path: 178 | raw_output = request.response.body 179 | return raw_output 180 | except Exception as e: 181 | log.error('Error acquiring location data: {}.'.format(e)) 182 | 183 | return False 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gmapslocsharing 2 | Definitely not a Home Assistant helper library designed to pretend to be a browser in order to grab location sharing GPS info instead of paying for the maps api... 3 | 4 | # Why you do this? 5 | Wasn't a huge fan of how locationsharinglib was working and processing information. Decided to see if I could conjure up a more consistent method. 6 | 7 | # Contents 8 | ## Manual install or venv: 9 | - custom_components folder: google_maps device_tracker component. 10 | - deps folder: gmapslocsharing package 11 | ## Docker 12 | - same as above 13 | - setup.sh script to add chromium browser & driver binary layer to HA's alpine 14 | along with entrypoint script to drop from root user to same UID:GID as local 15 | os. 16 | - sameple directory with standalone entrypoint and dockerfile contents. 17 | - custom docker image name = hass-chrome:latest 18 | 19 | # Dependencies 20 | - 2FA via device sign-in prompt on google account. 21 | - google-chrome-stable==77.0.3865.90 22 | - chromedriver-binary==77.0.3865.40.0 23 | - selenium==3.141.0 24 | - selenium-wire==1.0.9 25 | - geohash2==1.1 26 | 27 | # Instructions 28 | For the manual & venv installs, run the required scripts to install chrome/chromium 29 | browser and chromedriver. make sure custom components and deps contents are located 30 | in the appropriate destinations for your setup. 31 | 32 | ## HA Config 33 | `debug`: I've included a ton of debugging in case the component starts going a bit wonky. If things start going tits up, set debug to true and it'll output all the URLs, take screenshots of the login process, and dump raw output and errors to a debug folder under the HA config directory. 34 | 35 | ```yaml 36 | - platform: google_maps 37 | username: !secret google_maps_email 38 | password: !secret google_maps_pass 39 | debug: false 40 | ``` 41 | 42 | ## Manual Linux Install 43 | For the time being, there's just two scripts for manual linux installs. 44 | - Ubuntu 45 | - CentOS 46 | 47 | Throw them in your home folder, modify the paths as necessary, `chmod u+x` on the 48 | file and run. If you're on another OS, hit me up with a script for your use case 49 | so it can be added below. 50 | 51 | ### Ubuntu: 52 | ``` 53 | #!/bin/bash 54 | 55 | # modify this path as necessary to reflect your installation 56 | HA_PATH="$HOME/.homeassistant" 57 | TEMP="$HOME/gmaps_temp" 58 | 59 | wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 60 | 61 | cat << EOF | sudo tee /etc/apt/sources.list.d/google-chrome.list 62 | deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main 63 | EOF 64 | 65 | sudo apt update && sudo apt install google-chrome-stable git -y 66 | 67 | mkdir $TEMP 68 | git clone https://github.com/shr00mie/gmapslocsharing.git $TEMP 69 | cp -r $TEMP/custom_components $TEMP/deps $HA_PATH 70 | rm -rf $TEMP 71 | ``` 72 | ### Cent OS: (thanks, [lleone71](https://github.com/lleone71)!) 73 | ``` 74 | #!/bin/bash 75 | 76 | # modify this path as necessary to reflect your installation 77 | HA_PATH="$HOME/.homeassistant" 78 | TEMP="$HOME/gmaps_temp" 79 | 80 | cat << EOF | sudo tee /etc/yum.repos.d/google-chrome.repo 81 | [google-chrome] 82 | name=google-chrome 83 | baseurl=http://dl.google.com/linux/chrome/rpm/stable/$basearch 84 | enabled=1 85 | gpgcheck=1 86 | gpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub 87 | EOF 88 | 89 | sudo yum install google-chrome-stable git -y 90 | 91 | mkdir $TEMP 92 | git clone https://github.com/shr00mie/gmapslocsharing.git $TEMP 93 | cp -r $TEMP/custom_components $TEMP/deps $HA_PATH 94 | rm -rf $TEMP 95 | ``` 96 | ## Docker 97 | - Builds the custom image with a label:tag of hass-chrome:latest. 98 | 99 | Feel free to modify anything necessary to get this functional. I suspect this will 100 | become considerably more streamlined when converted to a proper PiPy package. 101 | 102 | # Updates 103 | [ 09.03.2019] 104 | - reconfigured gmapslocsharing for the new HA alpine image. setup.sh has been updated 105 | with the necessary changes. using chromium-browser and chromium-chromedriver from alpine's 106 | edge repo. should cut down or entirely eliminate browser and driver version mismatch. 107 | not all args are available for chromium, so bear with me while i get the right mix going. 108 | 109 | [ 08.15.2019] 110 | - fell down a rabbit hole optimizing the Dockerfile for the modified HA+Chrome 111 | image. 112 | - introduced entrypoint which grabs requirements as root and then switches to a 113 | predefined user:group to run the homeassistant as. 114 | - pretty big refactor of setup.sh. should now be able to just throw that in a 115 | script on your docker server, change some variables if necessary, and let it rip. 116 | - should go without saying, but examine the contents and modify for your environment 117 | as required. 118 | - dropped country HA config option under device_tracker platform. 119 | this will probably break shit, so if you were using it, check and 120 | remove it from your config. 121 | - updated the manual install scripts. 122 | 123 | [ 08.12.2019 ] 124 | - so...you know how sometimes you start replacing a light bulb and before you 125 | know it you're under the car replacing the O2 sensor...that's basically what happened. 126 | - entirely ripped out requests. replaced response body functionality via selenium-wire. 127 | - introduced configparser for passing data between modules. 128 | - NO MORE FUCKING COOKIE FILE. since we're using chrome, it's all in the chrome 129 | data folder. welcome to the future. 130 | - cleaned up raw response parsing. 131 | 132 | [ 07.25.2019 ] 133 | - the latest version of the chrome browser appears to be outputting decompressed 134 | bytes instead of brotli compressed data. refactored and removed brotli dependency. 135 | - while i was refactoring, think i managed to cover every edge case, so we 136 | should always be seeing complete data without any errors...at least until they tweak 137 | something. 138 | - i've also added geohash computation for anyone who likes messing around with 139 | grafana. right now it's defaulting to precision=12 for the granularity. as soon 140 | as my fingers stop hurting, i'll probably come back and add precision as a config 141 | option for anyone who needs it. 142 | 143 | [ 03.11.2019 ] 144 | - [jshank](https://github.com/jshank) was kind enough to be my guinea pig over the 145 | the weekend and get this package working with the docker HA install variant. 146 | The docker components can be found under the docker branch. That branch includes 147 | a Dockerfile and docker-compose template. The Dockerfile uses the existing HA 148 | Dockerfile and appends the necessary code to facilitate the google-chrome-stable 149 | install within the container. The docker-compose example is there for you to modify 150 | as necessary for your use case. 151 | - During the above adventure, a lot of...shortcomings?...were brought to light resulting 152 | in a rather comprehensive rebuild of large parts of the package. 153 | - In the not too distant future, I'd like to see about making this into a proper package 154 | such that it can be installed within any docker variant via pip and maybe PR this 155 | component over the existing and constantly breaking implementation within HA. 156 | 157 | # Astrixe's and whatnot 158 | I'd call this a solid beta at this point. I'm sure there are plenty of improvements 159 | to be made. Be gentle. If something breaks, set debug to true, and hit me up with 160 | the logs and contents from config/debug folder. 161 | 162 | I highly recommend enabling 2FA via device notifications on the account with 163 | which you are sharing individual locations. This allows for 2FA while not 164 | requiring any input/interaction with the initiator. This should allow for 165 | captcha bypass and seems like the best approach for this use case. 166 | 167 | # ToDo: 168 | - Figure out where things can go wrong. Catch said wrong things. Provide output 169 | to user. 170 | - Hass.io or HACAS implementation. 171 | 172 | # And then? 173 | If people like this, then I'm going to need a decent amount of help/input on how 174 | to turn this into a proper package as this would be my first. 175 | -------------------------------------------------------------------------------- /deps/lib/python3.6/site-packages/gmapslocsharing/core/location.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from datetime import datetime 3 | from .config import Config 4 | import logging 5 | import json 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | class Location: 10 | 11 | def __init__(self): 12 | 13 | log.debug('Initializing Location module.') 14 | self.config = Config() 15 | 16 | self.parsed_people = {} 17 | self.dict_people = {} 18 | self.people = [] 19 | 20 | self.debug_location = self.config.path_debug / 'location_debug' 21 | 22 | def parse_raw_people(self, raw_people:list) -> dict: 23 | ''' 24 | Input: self.raw_output list 25 | Performs: final cleanup of per person raw_output into dictionary of 26 | people using id:person as first level keys with key:value per 27 | person. regex and error catching for gps, battery, and scenarios 28 | where phone is on battery optimization mode. 29 | Returns: new_people dict containing {person_id:{key:value}} 30 | ''' 31 | 32 | pp = {} 33 | 34 | keep = { 35 | 0:'id', 36 | 2:'picture_url', 37 | 4:'full_name', 38 | 7:'gps', 39 | 8:'address', 40 | 10:'country', 41 | 20:'first_name', 42 | 21:'battery' 43 | } 44 | 45 | for raw_person in raw_people: 46 | raw_person = raw_person.split('"')[1:23] 47 | for i, item in enumerate(raw_person): 48 | if i in keep.keys(): 49 | if i == 0: 50 | id = int(item) 51 | name = keep[i] 52 | pp[id] = {'id': id} 53 | if i == 7: 54 | item = item.replace(']','').replace('\\n','').split(',')[3:7] 55 | pp[id].update( { 56 | 'latitude': float(item[1]), 57 | 'longitude': float(item[0]), 58 | 'accuracy': int(item[3]), 59 | 'last_seen': int(item[2]) / (10**3) 60 | }) 61 | elif i == 20: 62 | if len(item.split(' ')) == 1: 63 | pp[id].update({'first_name': item}) 64 | else: 65 | pp[id].update({'first_name': item.split(' ')[0]}) 66 | elif i == 21: 67 | item = item.split('[')[1].split(']')[0] 68 | item = item.split(',') if ',' in item else list(item) 69 | if len(item) == 1: 70 | pp[id].update( { 71 | 'battery_charging': True 72 | if int(item[0]) == 1 73 | else False, 74 | 'battery_level': None 75 | }) 76 | elif len(item) == 2: 77 | pp[id].update( { 78 | 'battery_charging': True 79 | if int(item[0]) == 1 80 | else False, 81 | 'battery_level': int(item[1]) 82 | }) 83 | else: 84 | pp[id].update({keep[i]: item}) 85 | return pp 86 | 87 | def update_people(self, formatted_people:dict) -> dict: 88 | ''' 89 | Input: dicts from new_people and old_people. 90 | Performs: additions/deletions to location sharing users and update 91 | old_people dict with new values for each person, if they are 92 | not null/none and different from existing data. 93 | Returns: updated old_people dict. 94 | ''' 95 | 96 | np = formatted_people 97 | op = self.dict_people if len(self.dict_people.keys()) > 0 else {} 98 | 99 | if len(op.keys()) == 0: 100 | log.debug('Copying new_people to old_people on first run.') 101 | op = np 102 | return op 103 | else: 104 | if np.keys() != op.keys(): 105 | add = [id for id in np.keys() if len(np.keys()) > 0 and id not in op.keys()] 106 | remove = [id for id in op.keys() if len(op.keys()) > 0 and id not in np.keys()] 107 | if len(add) != 0: 108 | log.debug('IDs to add: {}'.format(add)) 109 | if len(remove) != 0: 110 | log.debug('IDs to remove: {}'.format(remove)) 111 | for id in add: 112 | log.debug('Adding {}:{} to old_people.'.format(id, np[id])) 113 | op[id] = np[id] 114 | for id in remove: 115 | log.debug('Deleting {}:{} from old_people.'.format(id, op[id])) 116 | del op[id] 117 | 118 | elif np.keys() == op.keys(): 119 | log.debug('Comparing new and existing person data.') 120 | for id, person in np.items(): 121 | name = person['first_name'] 122 | for key, value in person.items(): 123 | new_value = np[id][key] 124 | old_value = op[id][key] 125 | if new_value != old_value and new_value is not None: 126 | log.debug('Updating {} for {} to {}.'.format(key, name, new_value)) 127 | op[id][key] = np[id][key] 128 | if len(op.keys()) > 0: 129 | return op 130 | 131 | def create_people(self, dict_people:dict) -> list: 132 | ''' 133 | Inputs: person class entity and old_people dict. 134 | Performs: conversion of old_people into list of named tuples. 135 | Returns: people objects to be returned back to HA for consumption. 136 | ''' 137 | 138 | people = [] 139 | for id, person in dict_people.items(): 140 | Person = namedtuple('Person', ' '.join([key for key in person.keys()])) 141 | people.append(Person(**person)) 142 | return people 143 | 144 | def update(self, raw_output): 145 | ''' 146 | Performs: location update for gmapslocsharing. 147 | ''' 148 | 149 | log.debug('Updating location sharing data.') 150 | 151 | go_on = True 152 | 153 | if go_on: 154 | try: 155 | log.debug('Parsing raw people.') 156 | self.parsed_people = self.parse_raw_people(raw_output) 157 | self.debug('parsed_people', self.parsed_people) 158 | except Exception as e: 159 | log.info('Error parsing raw people: {}.'.format(e)) 160 | go_on=False 161 | 162 | if go_on: 163 | try: 164 | log.debug('Updating people.') 165 | self.dict_people = self.update_people(self.parsed_people) 166 | self.debug('dict_people', self.dict_people) 167 | except Exception as e: 168 | log.info('Error updating people: {}.'.format(e)) 169 | go_on=False 170 | 171 | if go_on: 172 | try: 173 | log.debug('Creating {} people.'.format(len(self.dict_people.keys()))) 174 | self.people = self.create_people(self.dict_people) 175 | self.debug('people', self.people) 176 | log.debug('Location update completed successfully.') 177 | return self.people 178 | except Exception as e: 179 | log.info('Error converting people: {}.'.format(e)) 180 | 181 | 182 | def debug(self, source, data): 183 | 184 | if self.config.debug: 185 | 186 | path = self.config.path_debug_location 187 | 188 | if not path.exists(): 189 | path.mkdir(mode=0o770, parents=True) 190 | 191 | timestamp = datetime.now().strftime('%Y-%m-%d - %H:%M:%S') 192 | parsed_path = path / 'parsed_people' 193 | dict_path = path / 'dict_people' 194 | person_path = path / 'people' 195 | 196 | if source == 'parsed_people': 197 | with parsed_path.open('a+') as f: 198 | f.write('{}\n'.format(timestamp)) 199 | for id, person in data.items(): 200 | f.write('{}\n'.format(json.dumps(person, sort_keys=False, indent=4))) 201 | f.write('\n') 202 | 203 | if source == 'dict_people': 204 | with dict_path.open('a+') as f: 205 | f.write('{}\n'.format(timestamp)) 206 | for id, person in data.items(): 207 | f.write('{}\n'.format(json.dumps(person, sort_keys=False, indent=4))) 208 | f.write('\n') 209 | 210 | if source == 'people': 211 | with person_path.open('a+') as f: 212 | f.write('{}\n'.format(timestamp)) 213 | for person in data: 214 | f.write('{}\n'.format(json.dumps(person, sort_keys=False, indent=4))) 215 | f.write('\n') 216 | --------------------------------------------------------------------------------