This commit is contained in:
Cyberes 2024-09-03 14:30:50 -06:00
parent 59fca41ffa
commit d1110dece8
10 changed files with 268 additions and 2 deletions

4
.gitignore vendored
View File

@ -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/

View File

@ -1,3 +1,19 @@
# ha-location-exporter
Export your device location and save it in a database.
*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`.

19
config.sample.yml Normal file
View File

@ -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

View File

@ -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

0
lib/__init__.py Normal file
View File

46
lib/dawarich.py Normal file
View File

@ -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}')

16
lib/models.py Normal file
View File

@ -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)

3
lib/strings.py Normal file
View File

@ -0,0 +1,3 @@
def mysql_trim_float(f):
s = str(f)
return float(s.split('.')[0] + '.' + s.split('.')[-1][:14])

146
main.py Normal file
View File

@ -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()

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
requests==2.32.3
sqlalchemy==2.0.33
pyyaml==6.0.2
mysqlclient==2.2.4
dateparser==1.2.0