From d1110dece88065afd762fc70525e60800d436741 Mon Sep 17 00:00:00 2001 From: Cyberes Date: Tue, 3 Sep 2024 14:30:50 -0600 Subject: [PATCH] add code --- .gitignore | 4 +- README.md | 18 ++++- config.sample.yml | 19 +++++ ha-location-export.service | 13 ++++ lib/__init__.py | 0 lib/dawarich.py | 46 ++++++++++++ lib/models.py | 16 ++++ lib/strings.py | 3 + main.py | 146 +++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ 10 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 config.sample.yml create mode 100644 ha-location-export.service create mode 100644 lib/__init__.py create mode 100644 lib/dawarich.py create mode 100644 lib/models.py create mode 100644 lib/strings.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 5d381cc..6a28821 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.idea +config.yml + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ @@ -15,7 +18,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/README.md b/README.md index 9588749..da797e0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ # ha-location-exporter -Export your device location and save it in a database. \ No newline at end of file +*Export your device location and save it in a database.* + +## Install + +```shell +sudo apt-get install mariadb-client libmariadb-dev +``` + +```shell +pip install -r requirements.txt +``` + +```shell +cp config.sample.yml config.yml +``` + +Then, edit `config.yml`. diff --git a/config.sample.yml b/config.sample.yml new file mode 100644 index 0000000..02e8d69 --- /dev/null +++ b/config.sample.yml @@ -0,0 +1,19 @@ +ha_url: https://example.com +access_token: abc123 +entities: + - device_tracker.your_phone + +interval: 1800 + +database: + host: 127.0.0.1 + database: ha_locations + username: ha_locations + password: pw123 + +dawarich: + host: http://127.0.0.1:3000 + api_key: abc123 + + # Only one entity is supported on Dawarich. + entity_id: device_tracker.your_phone diff --git a/ha-location-export.service b/ha-location-export.service new file mode 100644 index 0000000..3f4446d --- /dev/null +++ b/ha-location-export.service @@ -0,0 +1,13 @@ +[Unit] +Description=Home Assistant Location Export +After=network.target + +[Service] +Type=simple +User=homeassistant +ExecStart=/srv/ha-location-exporter/ha-location-exporter/venv/bin/python /srv/ha-location-exporter/ha-location-exporter/main.py +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/dawarich.py b/lib/dawarich.py new file mode 100644 index 0000000..24fd8ca --- /dev/null +++ b/lib/dawarich.py @@ -0,0 +1,46 @@ +import logging + +import requests + +_logger = logging.getLogger('MAIN').getChild('DAWARICH') + + +def send_to_dawarich(entity_id, timestamp, latitude, longitude, gps_accuracy, vertical_accuracy, altitude, battery, speed_kph, config_data): + url = f'{config_data["dawarich"]["host"]}/api/v1/overland/batches?api_key={config_data["dawarich"]["api_key"]}' + data = { + "locations": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + longitude, + latitude + ] + }, + "properties": { + "api_key": config_data['dawarich']['api_key'], + "timestamp": timestamp.isoformat(), + "altitude": altitude, + "speed": speed_kph, + "horizontal_accuracy": gps_accuracy, + "vertical_accuracy": vertical_accuracy, + "motion": [], + "pauses": False, + "activity": "", + "desired_accuracy": 0, + "deferred": 0, + "significant_change": "unknown", + "locations_in_payload": 1, + "device_id": entity_id, + "wifi": "unknown", + "battery_state": "unknown", + "battery_level": battery + } + } + ] + } + headers = {'Content-Type': 'application/json'} + response = requests.post(url, json=data, headers=headers) + if response.status_code != 201: + _logger.error(f'Failed to send data to dawarich for entity "{entity_id}": {response.status_code} -- {response.text}') diff --git a/lib/models.py b/lib/models.py new file mode 100644 index 0000000..a7d3828 --- /dev/null +++ b/lib/models.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, Text, DateTime, Numeric, JSON +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class HALocations(Base): + __tablename__ = 'ha_locations' + + device = Column(Text, nullable=False) + lat = Column(Numeric(precision=20, scale=14), nullable=False) + lon = Column(Numeric(precision=20, scale=14), nullable=False) + state = Column(Text, nullable=False) + attributes = Column(JSON, nullable=False, default={}) + timestamp = Column(DateTime(timezone=True), nullable=False) + id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/lib/strings.py b/lib/strings.py new file mode 100644 index 0000000..52455a0 --- /dev/null +++ b/lib/strings.py @@ -0,0 +1,3 @@ +def mysql_trim_float(f): + s = str(f) + return float(s.split('.')[0] + '.' + s.split('.')[-1][:14]) diff --git a/main.py b/main.py new file mode 100644 index 0000000..20dd7be --- /dev/null +++ b/main.py @@ -0,0 +1,146 @@ +import logging +import os +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +import requests +import yaml +from dateparser import parse +from sqlalchemy import create_engine, func +from sqlalchemy.orm import sessionmaker + +from lib.dawarich import send_to_dawarich +from lib.models import HALocations, Base +from lib.strings import mysql_trim_float + +logging.basicConfig() +_logger = logging.getLogger('MAIN') + +CONFIG_DATA = {} + + +def get_battery_level(entity_id, timestamp): + battery_entity_id = f"sensor.{entity_id.split('.')[1]}_battery_level" + start_time = timestamp - timedelta(minutes=1) + + response = requests.get( + f"{CONFIG_DATA['ha_url']}/api/history/period/{start_time.isoformat()}?filter_entity_id={battery_entity_id}", + headers={"Authorization": f"Bearer {CONFIG_DATA['access_token']}"} + ) + + if response.status_code == 200: + battery_history = response.json()[0] + if battery_history: + for entry in reversed(battery_history): + entry_timestamp = parse(entry["last_changed"]) + if entry_timestamp <= timestamp: + state = entry.get("state") + return float(state) if state is not None else None + _logger.warning(f'No battery level data found for entity "{battery_entity_id}" at or before timestamp {timestamp}') + return None + else: + _logger.warning(f'No battery level data found for entity "{battery_entity_id}" at or before timestamp {timestamp}') + return None + else: + _logger.error(f'Failed to fetch battery level for entity "{battery_entity_id}" at or before timestamp {timestamp}: {response.status_code} -- {response.text}') + return None + + +def main(): + global CONFIG_DATA + _logger.setLevel(logging.DEBUG) + + config_path = Path(os.path.dirname(os.path.realpath(__file__)), 'config.yml') + if not config_path.exists(): + _logger.critical(f'Config file not found: {config_path}') + sys.exit(1) + + with open(config_path) as stream: + try: + CONFIG_DATA = yaml.safe_load(stream) + except yaml.YAMLError as e: + _logger.critical(f'Failed to load config file: {e}') + sys.exit(1) + + _logger.info('Initalizing database...') + engine = create_engine(f'mysql://{CONFIG_DATA["database"]["username"]}:{CONFIG_DATA["database"]["password"]}@{CONFIG_DATA["database"]["host"]}{":" + str(CONFIG_DATA["database"]["port"]) if CONFIG_DATA["database"].get("port") else ""}/{CONFIG_DATA["database"]["database"]}') + Session = sessionmaker(bind=engine) + Base.metadata.create_all(engine) + + while True: + end_time = datetime.now() + start_time = end_time - timedelta(hours=24) + + for entity_id in CONFIG_DATA['entities']: + _logger.info(f'Fetching entity "{entity_id}"') + response = requests.get( + f"{CONFIG_DATA['ha_url']}/api/history/period/{start_time.isoformat()}?filter_entity_id={entity_id}", + headers={"Authorization": f"Bearer {CONFIG_DATA['access_token']}"}, + ) + + new_rows = 0 + skipped_rows = 0 + + if response.status_code == 200: + location_history = response.json()[0] + session = Session() + + for entry in location_history: + timestamp = parse(entry["last_changed"]) + state = entry["state"] + attributes = entry["attributes"] + latitude = mysql_trim_float(attributes.get("latitude")) + longitude = mysql_trim_float(attributes.get("longitude")) + + existing_entry = session.query(HALocations).filter( + func.round(HALocations.lat, 6) == round(latitude, 6), + func.round(HALocations.lon, 6) == round(longitude, 6), + func.date(HALocations.timestamp) == datetime.strptime(str(timestamp).strip('+00:00'), '%Y-%m-%d %H:%M:%S.%f').date(), + func.hour(HALocations.timestamp) == datetime.strptime(str(timestamp).strip('+00:00'), '%Y-%m-%d %H:%M:%S.%f').hour, + func.minute(HALocations.timestamp) == datetime.strptime(str(timestamp).strip('+00:00'), '%Y-%m-%d %H:%M:%S.%f').minute, + HALocations.device == entity_id + ).first() + + if existing_entry is None: + batt_level = get_battery_level(entity_id, timestamp) + if batt_level is not None: + attributes['battery'] = batt_level + else: + attributes['battery'] = -1 + + new_entry = HALocations( + device=entity_id, + lat=latitude, + lon=longitude, + state=state, + attributes=attributes, + timestamp=timestamp + ) + session.add(new_entry) + + if CONFIG_DATA.get('dawarich') and entity_id == CONFIG_DATA['dawarich']['entity_id']: + send_to_dawarich( + entity_id, + timestamp, + latitude, + longitude, + attributes['gps_accuracy'], attributes['vertical_accuracy'], attributes['altitude'], attributes['battery'], attributes['speed'], CONFIG_DATA) + + new_rows += 1 + else: + skipped_rows += 1 + + _logger.info(f'Committing changes to database for entity "{entity_id}". New: {new_rows}. Skipped: {skipped_rows}') + session.commit() + session.close() + else: + _logger.error(f'Failed to fetch the location history: {response.status_code} -- {response.text}') + + _logger.info('History fetch complete') + time.sleep(CONFIG_DATA['interval']) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..415afb8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests==2.32.3 +sqlalchemy==2.0.33 +pyyaml==6.0.2 +mysqlclient==2.2.4 +dateparser==1.2.0 \ No newline at end of file