├── .gitignore ├── Makefile ├── README.md ├── cfg.json.example ├── requirements.txt └── src ├── actions ├── __init__.py ├── email.py └── http.py ├── config ├── __init__.py └── cfg.py ├── fitbit ├── __init__.py └── api.py ├── main.py └── utils ├── __init__.py ├── debug.py └── format.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | mkdir -p /etc/fhm/ 3 | cp -n cfg.json.example /etc/fhm/cfg.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fitbit Heartrate Monitor 2 | An application that interacts with [Fitbit's](https://fitbit.com/) [Web API](https://dev.fitbit.com/build/reference/web-api) to retrieve information on a person's heart rate. If the heart rate goes below or above a certain threshold, certain actions may be performed such as sending an email or HTTP request. 3 | 4 | ## Warning! 5 | I've completed this program, but it is **un-tested** since my old FitBit stopped working and I'll need to buy a new one. I'm not sure how reliable the web API hook we [use](https://dev.fitbit.com/build/reference/web-api/intraday/get-heartrate-intraday-by-date/) is (e.g. does it actually pull the current heart rate?). If it doesn't, I believe you'd need to create an application for FitBit directly since you'd need access to the device. I'm not sure how hard that is to do and if you need to be given access to do so. 6 | 7 | ## Command Line 8 | The following command-line options are available. 9 | 10 | ``` 11 | cfg= => Custom config path. 12 | -l or --list => List all contents from config file. 13 | ``` 14 | 15 | ### Examples 16 | ```bash 17 | # List contents of config. 18 | python3 src/main.py -l 19 | 20 | # Use custom.json as config file in working directory. 21 | python3 src/main.py cfg=./custom.json 22 | ``` 23 | 24 | ## Configuration 25 | All configuration is handled in a file with the JSON format. The default config is `/etc/fhm/cfg.json`. You may find documented configuration below. 26 | 27 | ``` 28 | { 29 | // Debug level from 0 - 3 (0 = no debug). 30 | "Debug": 0, 31 | 32 | // Authorization Bearer key (do not include "Bearer"). 33 | "Authorization": "", 34 | 35 | // Fitbit user's ID. '-' indicates currently signed in user. 36 | "UserID": "-", 37 | 38 | // Low threshold for triggering heart rate action. 39 | "LowThreshold": 50, 40 | 41 | // High threshold for trigger heart rate action. 42 | "HighThreshold": 120, 43 | 44 | // The amount of previous heart rates to include in average. 45 | "AvgCount": 10, 46 | 47 | // The time in seconds to timeout after a successful heart rate detection to prevent spam. 48 | "DetectTimeout": 120, 49 | 50 | // Actions array. 51 | "Actions": 52 | [ 53 | // Action: Send HTTP request. 54 | { 55 | // Type of action (HTTP in this case). 56 | "Type": "http", 57 | 58 | // URL to send HTTP request to. 59 | "Url": "testdomain.com?get1=val1&get2=val2", 60 | 61 | // The HTTP request's method (e.g. GET or POST). 62 | "Method": "GET", 63 | 64 | // HTTP requests timeout (default 5.0). 65 | "Timeout": 5.0, 66 | 67 | // Headers (if any). 68 | "Headers": { 69 | "Authorization": "Bearer " 70 | } 71 | 72 | // Request's body (most useful for POST request; For GET, this is params). 73 | "Body": {} 74 | }, 75 | // Action: Send Email 76 | { 77 | // Type of action (Email in this case). 78 | "Type": "email", 79 | 80 | // SMTP host. 81 | "Host": "localhost", 82 | 83 | // SMTP port. 84 | "Port": 25, 85 | 86 | // From email. 87 | "FromEmail": "ian.demi@testdomain.com", 88 | 89 | // Emails to send to in an array. 90 | "ToEmail": ["christian.deacon@anothertestdomain.com"], 91 | 92 | // Subject of email. 93 | "Subject": "Heart rate warning!", 94 | 95 | // Message/body of email. 96 | "Message": "Your heart rate is currently too high or too low!" 97 | } 98 | ] 99 | } 100 | ``` 101 | 102 | **Warning** - The above JSON is **not** valid due to comments. Please use the `cfg.json.example` if you want to start from somewhere (preferably with `sudo make install`) 103 | 104 | ### Action Format Options 105 | Format options for an action's HTTP URL/body or email subject/body message include the following. 106 | 107 | ``` 108 | {avg_rate} => The average heart rate detected. 109 | {low_or_high} => Prints "Low" if the average heart rate was below threshold or "High" if the average heart rate was above threshold. 110 | {cfg_high_threshold} => The config's high theshold value. 111 | {cfg_low_threshold} => The config's low threshold value. 112 | {cfg_count} => The config's average count value. 113 | ``` 114 | 115 | ## Motives 116 | I currently live with a family member who has health issues with their heart and high blood pressure. I wanted to make something to alert my other family members and I if this person's heart rate is abnormally low or high. 117 | 118 | ## Credits 119 | * [Christian Deacon](https://github.com/gamemann) 120 | -------------------------------------------------------------------------------- /cfg.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "Debug": 0, 3 | "Authorization": "", 4 | "UserID": "-", 5 | "LowThreshold": 50, 6 | "HighThreshold": 120, 7 | "AvgCount": 10, 8 | "DetectTimeout": 120, 9 | "Actions": 10 | [ 11 | 12 | ] 13 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests -------------------------------------------------------------------------------- /src/actions/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Actions" 2 | __version__ = "1.0.0" 3 | 4 | from .email import * 5 | from .http import * -------------------------------------------------------------------------------- /src/actions/email.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | 3 | from email.mime.multipart import MIMEMultipart 4 | from email.mime.text import MIMEText 5 | from email.header import Header 6 | 7 | def send_email(host: str = "localhost", port: int = 25, from_email: str = "test@localhost", to_email: list[str] = ["test@localhost"], subject: str = "Heartrate Threshold!", message: str = "Test contents!"): 8 | smtp = smtplib.SMTP(host=host, port=port) 9 | 10 | for to in to_email: 11 | # Create MIME Multipart object. 12 | mime = MIMEMultipart("alternative") 13 | 14 | # Set the subject. 15 | mime["Subject"] = Header(subject, "utf-8") 16 | 17 | # Set from email. 18 | mime["From"] = from_email 19 | 20 | # Set to email. 21 | mime["To"] = to 22 | 23 | # Parse body as text/html and attach to our MIME object. 24 | body = MIMEText(message, 'html') 25 | mime.attach(body) 26 | 27 | # Attemp to send mail. 28 | try: 29 | smtp.sendmail(from_email, to_email, mime.as_string()) 30 | except smtplib.SMTPException as e: 31 | print("Error sending email %s to %s" % (from_email, to)) 32 | print(e) 33 | 34 | smtp.quit() 35 | -------------------------------------------------------------------------------- /src/actions/http.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def send_http_request(url: str, method: str = "POST", headers: dict = {}, body: dict = {}, timeout: float = 5.0): 4 | resp = None 5 | 6 | try: 7 | if method.lower() == "post": 8 | resp = requests.post(url, data=body, headers=headers, timeout=timeout) 9 | else: 10 | resp = requests.get(url, params=body, headers=headers) 11 | except Exception as e: 12 | print("Failed to send HTTP request.") 13 | print(e) 14 | 15 | return resp -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Config" 2 | __version__ = "1.0.0" 3 | 4 | from .cfg import * -------------------------------------------------------------------------------- /src/config/cfg.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | def loadCFG(path: str) -> dict: 4 | cfg: dict = {} 5 | 6 | # Open config file 7 | with open(path) as file: 8 | cfg = json.load(file) 9 | 10 | # Set defaults. 11 | if "Debug" not in cfg: 12 | cfg["Debug"] = 0 13 | 14 | if "Authorization" not in cfg: 15 | cfg["Authorization"] = "" 16 | 17 | if "UserID" not in cfg: 18 | cfg["UserID"] = "-" 19 | 20 | if "LowThreshold" not in cfg: 21 | cfg["LowThreshold"] = 50 22 | 23 | if "HighThreshold" not in cfg: 24 | cfg["HighThreshold"] = 120 25 | 26 | if "AvgCount" not in cfg: 27 | cfg["AvgCount"] = 10 28 | 29 | if "Actions" not in cfg: 30 | cfg["Actions"] = [] 31 | 32 | if "DetectTimeout" not in cfg: 33 | cfg["DetectTimeout"] = 120 34 | 35 | return cfg 36 | -------------------------------------------------------------------------------- /src/fitbit/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Fitbit" 2 | __version__ = "1.0.0" 3 | 4 | from .api import * -------------------------------------------------------------------------------- /src/fitbit/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | import utils 4 | 5 | def retrieve_heartrates(cfg: dict) -> list | None: 6 | ret: list = [] 7 | 8 | # Dummy data for now. 9 | # return [70, 72, 74, 70, 65, 60] 10 | 11 | # Send HTTP request and retrieve response. 12 | api_url: str = "https://api.fitbit.com/1/user/" + cfg["UserID"] + "/activities/heart/date/today/1d/1sec.json" 13 | 14 | resp = requests.get(api_url, headers={"Authorization": "Bearer " + cfg["Authorization"]}) 15 | 16 | # check status code. 17 | if resp.status_code != 200: 18 | print("Error retrieving heart rates! Status code => " + str(resp.status_code)) 19 | 20 | return None 21 | 22 | # Debug message. 23 | utils.debug_message(cfg, 3, "Heart rates JSON => " + resp.json()) 24 | 25 | # Parse data. 26 | data = resp.json()["activities-heart"][0]["activities-heart-intraday"]["dataset"] 27 | 28 | if data is None: 29 | print("Heart rates data is none.") 30 | 31 | return None 32 | 33 | # Retrieve last x amount of items on list where x is the average count config setting. 34 | items = data[-(int(cfg["AvgCount"])):] 35 | 36 | # Loop through each item in the list and append to our return. 37 | for ele in items.items(): 38 | ret.append(int(ele["value"])) 39 | 40 | return ret 41 | 42 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | import config 5 | import fitbit 6 | import actions 7 | import utils 8 | 9 | def main(): 10 | cfg_file: str = "/etc/fhm/cfg.json" 11 | list: bool = False 12 | 13 | # Parse arguments. 14 | for arg in sys.argv: 15 | # Check for custom config path. 16 | if arg.startswith("cfg="): 17 | cfg_file = arg.split("=")[1] 18 | 19 | # Check for list. 20 | if arg == "-l" or arg == "--list": 21 | list = True 22 | 23 | # Load config. 24 | cfg = config.loadCFG(cfg_file) 25 | 26 | # Check if we need to print config. 27 | if list: 28 | print(cfg) 29 | 30 | return 31 | 32 | # Create an infinite loop that executes each second. 33 | while True: 34 | utils.debug_message(cfg, 3, "Retrieving heart rates...") 35 | 36 | rates: list[int] | None = None 37 | 38 | # Check if we should force heart rate values. 39 | if "ForceHeartrates" in cfg: 40 | rates = list(cfg["ForceHeartRates"]) 41 | else: 42 | # Retrieve our heart rates as a list. 43 | rates = fitbit.retrieve_heartrates(cfg) 44 | 45 | if rates is None: 46 | print("Error retrieving heart rates... Sleeping for 5 seconds.") 47 | 48 | time.sleep(5) 49 | 50 | # Get the average between our heart rates. 51 | avg_rate: int = int(sum(rates) / len(rates)) 52 | 53 | utils.debug_message(cfg, 3, "Heart rates retrieved :: Avg => " + str(avg_rate)) 54 | 55 | # Check if we're below or above threshold. 56 | if avg_rate > int(cfg["HighThreshold"]) or avg_rate < int(cfg["LowThreshold"]): 57 | utils.debug_message(cfg, 1, "Heart rates are below or above thresholds!") 58 | 59 | # Determine if we have a low or high threshold. 60 | low_or_high: str = "High" 61 | 62 | if avg_rate < int(cfg["LowThreshold"]): 63 | low_or_high = "Low" 64 | 65 | # Retrieve default formats for our action messages. 66 | formats = utils.retrieve_formats(cfg, avg_rate, low_or_high) 67 | 68 | # Loop through actions. 69 | for action in cfg["Actions"]: 70 | utils.debug_message(cfg, 3, "Parsing action...") 71 | 72 | # Ensure we have a type. 73 | if "Type" not in "action": 74 | print("Action doesn't contain a type!") 75 | 76 | continue 77 | 78 | # Check for HTTP request. 79 | if action["Type"].lower() == "http": 80 | utils.debug_message(cfg, 2, "Found action with type HTTP!") 81 | 82 | # Make sure we have a URL set. 83 | if "Url" not in action: 84 | print("Action of HTTP request does NOT contain a URL!") 85 | 86 | continue 87 | 88 | url: str = str(action["Url"]) 89 | 90 | # Format URL in the case of GET request. 91 | url = utils.format_message(url, formats) 92 | 93 | method: str = "POST" 94 | 95 | if "Method" in action: 96 | method = str(action["Method"]) 97 | 98 | timeout: float = 5.0 99 | 100 | if "Timeout" in action: 101 | timeout = float(action["Timeout"]) 102 | 103 | headers: dict[str, str] = {} 104 | 105 | if "Headers" in action: 106 | headers = action["Headers"] 107 | 108 | body: dict[str, str] = {} 109 | 110 | if "Body" in action: 111 | body = action["Body"] 112 | 113 | # Format values for body. 114 | for key, val in body.items(): 115 | newVal = utils.format_message(val, formats) 116 | 117 | body[key] = newVal 118 | 119 | # Make request and retrieve response. 120 | resp = actions.send_http_request(action["Url"], method, headers, body, timeout) 121 | 122 | utils.debug_message(cfg, 1, "Sending HTTP request :: %s (method => %s)!" % (action["Url"], method)) 123 | 124 | utils.debug_message(cfg, 3, "HTTP request status code => %d. JSON Response => %s." % (resp.status_code, resp.json())) 125 | # Otherwise, we want to send an email. 126 | else: 127 | utils.debug_message(cfg, 2, "Found action with type email!") 128 | # Retrieve SMTP/email configuration. 129 | host: str = "localhost" 130 | 131 | if "Host" in action: 132 | host = str(action["Host"]) 133 | 134 | port: int = 25 135 | 136 | if "Port" in action: 137 | port = int(action["Port"]) 138 | 139 | from_email: str = "test@localhost" 140 | 141 | if "FromEmail" in action: 142 | from_email = str(action["FromEmail"]) 143 | 144 | to_email: list[str] = ["test@localhost"] 145 | 146 | if "ToEmail" in action: 147 | to_email = action["ToEmail"] 148 | 149 | subject: str = "Heartrate threshold!" 150 | 151 | if "Subject" in action: 152 | subject = str(action["Subject"]) 153 | 154 | # Format subject. 155 | subject = utils.format_message(subject, formats) 156 | 157 | message: str = "Test contents!" 158 | 159 | if "Message" in action: 160 | message = str(action["Message"]) 161 | 162 | # Format message. 163 | message = utils.format_message(message, formats) 164 | 165 | # Send email. 166 | actions.send_email(host, port, from_email, to_email, subject, message) 167 | 168 | # Debug 169 | utils.debug_message(cfg, 1, "Sending email :: %s => %s!" % (from_email, to_email)) 170 | 171 | # Sleep for detect timeout. 172 | time.sleep(int(cfg["DetectTimeout"])) 173 | 174 | time.sleep(1) 175 | 176 | if __name__ == "__main__": 177 | main() -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Utils" 2 | __version__ = "1.0.0" 3 | 4 | from .format import * 5 | from .debug import * -------------------------------------------------------------------------------- /src/utils/debug.py: -------------------------------------------------------------------------------- 1 | def debug_message(cfg: dict, level: int = 1, message: str = "Debug Message"): 2 | if int(cfg["Debug"]) >= level: 3 | print("[%d] %s" % (level, message)) -------------------------------------------------------------------------------- /src/utils/format.py: -------------------------------------------------------------------------------- 1 | def format_message(message: str, format: dict = {}): 2 | ret = message 3 | 4 | for rep, val in format.items(): 5 | ret = ret.replace(rep, val) 6 | 7 | return ret 8 | 9 | def retrieve_formats(cfg: dict, avg_rate: int, low_or_high: str = "Low") -> dict: 10 | return { 11 | "{avg}": avg_rate, 12 | "{low_or_high}": low_or_high, 13 | "{cfg_high_threshold}": cfg["HighThreshold"], 14 | "{cfg_low_threshold}": cfg["LowThreshold"], 15 | "{cfg_count}": cfg["AvgCount"] 16 | } --------------------------------------------------------------------------------