From 4e4e3a7f5acbfbb197123e5784661d67ec33886f Mon Sep 17 00:00:00 2001 From: Keith Herrington Date: Thu, 22 Aug 2024 20:19:35 -0700 Subject: [PATCH] Initial commit --- .gitignore | 7 +++ README.md | 86 ++++++++++++++++++++++++++ config.py | 23 +++++++ hostex_api.py | 23 +++++++ ics_parser.py | 34 ++++++++++ main.py | 51 +++++++++++++++ notification.py | 20 ++++++ requirements.txt | 4 ++ unifi.conf.example | 26 ++++++++ unifi_access.py | 150 +++++++++++++++++++++++++++++++++++++++++++++ utils.py | 19 ++++++ 11 files changed, 443 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 hostex_api.py create mode 100644 ics_parser.py create mode 100644 main.py create mode 100644 notification.py create mode 100644 requirements.txt create mode 100644 unifi.conf.example create mode 100644 unifi_access.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5edc7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +unifi.conf +*.log +__pycache__/ +*.pyc +.vscode/ +.idea/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..b68a0ca --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# UniFi Access Airbnb Integration + +This project integrates UniFi Access with Airbnb reservations, automating the process of creating and managing visitor access for your Airbnb guests. + +## Features + +- Fetch reservations from Hostex API or Airbnb ICS feed +- Create UniFi Access visitor accounts for upcoming guests +- Assign PIN codes to visitors based on their phone number +- Automatically delete past or completed visitor accounts +- Send notifications via Simplepush for updates and failures + +## Prerequisites + +- Python 3.7+ +- UniFi Access system +- Airbnb account with ICS feed URL or Hostex API access + +## Installation + +1. Clone the repository: +git clone https://github.com/yourusername/unifi-access-airbnb.git +cd unifi-access-airbnb + +2. Install the required packages: +pip install -r requirements.txt + +3. Copy the example configuration file and edit it with your settings: +cp unifi.conf.example unifi.conf +nano unifi.conf + +## Configuration + +Edit the `unifi.conf` file with your specific settings. Here's an explanation of each section: + +- `[UniFi]`: UniFi Access API settings +- `[Hostex]`: Hostex API settings (if used) +- `[Airbnb]`: Airbnb ICS feed URL (if used) +- `[Simplepush]`: Simplepush notification settings +- `[Door]`: Default door group ID for visitor access +- `[Visitor]`: Check-in and check-out times +- `[General]`: General settings like log file name and PIN code length + +## Usage + +Run the script using: +python main.py + +Optional arguments: +- `-v` or `--verbose`: Increase output verbosity +- `-l [LOG_FILE]` or `--log [LOG_FILE]`: Specify a log file (default is set in the config file) + +## Scheduling + +To run the script automatically, you can set up a cron job. For example, to run it every hour: + +1. Open your crontab file: +crontab -e +2. Add the following line (adjust the path as needed): +0 * * * * /usr/bin/python3 /path/to/unifi-access-airbnb/main.py + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the project +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- UniFi Access for their API +- Hostex for their reservation management API +- Airbnb for providing ICS feed functionality +- Simplepush for their notification service + +## Support + +If you encounter any problems or have any questions, please open an issue on the GitHub repository. + diff --git a/config.py b/config.py new file mode 100644 index 0000000..1eb1503 --- /dev/null +++ b/config.py @@ -0,0 +1,23 @@ +import configparser + +def load_config(): + config = configparser.ConfigParser() + config.read('unifi.conf') + + return { + 'api_host': config['UniFi']['api_host'], + 'api_token': config['UniFi']['api_token'], + 'hostex_api_url': config['Hostex']['api_url'], + 'hostex_api_key': config['Hostex']['api_key'], + 'ics_url': config.get('Airbnb', 'ics_url', fallback=None), + 'simplepush_enabled': config['Simplepush'].getboolean('enabled', fallback=False), + 'simplepush_key': config['Simplepush'].get('key', fallback=None), + 'simplepush_url': config['Simplepush'].get('url', fallback=None), + 'default_door_group_id': config['Door']['default_group_id'], + 'check_in_time': config['Visitor']['check_in_time'], + 'check_out_time': config['Visitor']['check_out_time'], + 'use_hostex': 'Hostex' in config and config['Hostex']['api_key'], + 'use_ics': config.get('Airbnb', 'ics_url', fallback=None) is not None, + 'log_file': config['General']['log_file'], + 'pin_code_digits': int(config['General']['pin_code_digits']) + } diff --git a/hostex_api.py b/hostex_api.py new file mode 100644 index 0000000..ab4b6af --- /dev/null +++ b/hostex_api.py @@ -0,0 +1,23 @@ +import requests +import logging + +class HostexManager: + def __init__(self, config): + self.api_url = config['hostex_api_url'] + self.api_key = config['hostex_api_key'] + self.logger = logging.getLogger(__name__) + + def fetch_reservations(self): + url = f"{self.api_url}/reservations" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + response = requests.get(url, headers=headers) + if response.status_code == 200: + reservations = response.json()["data"]["reservations"] + self.logger.debug(f"Fetched {len(reservations)} reservations from Hostex") + return reservations + else: + self.logger.error(f"Failed to fetch reservations from Hostex. Status code: {response.status_code}") + return [] diff --git a/ics_parser.py b/ics_parser.py new file mode 100644 index 0000000..d7d82dd --- /dev/null +++ b/ics_parser.py @@ -0,0 +1,34 @@ +import requests +import icalendar +import datetime +import logging + +class ICSParser: + def __init__(self, config): + self.ics_url = config['ics_url'] + self.logger = logging.getLogger(__name__) + + def parse_ics(self): + response = requests.get(self.ics_url) + cal = icalendar.Calendar.from_ical(response.text) + reservations = [] + for event in cal.walk("VEVENT"): + start = event.get("DTSTART").dt + end = event.get("DTEND").dt + description = event.get("DESCRIPTION", "") + if not description: + self.logger.debug(f"Skipping event with start date {start.date()} due to missing description") + continue + pin_code = "" + for line in description.split("\n"): + if line.startswith("Phone Number (Last 4 Digits):"): + pin_code = line.split(": ")[1].strip() + break + reservations.append({ + "check_in_date": start.date() if isinstance(start, datetime.datetime) else start, + "check_out_date": end.date() if isinstance(end, datetime.datetime) else end, + "guests": [{"name": "Airbnb Guest", "phone": pin_code}], + "status": "accepted" + }) + self.logger.debug(f"Parsed {len(reservations)} reservations from ICS file") + return reservations diff --git a/main.py b/main.py new file mode 100644 index 0000000..50e981c --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +import argparse +import logging +from config import load_config +from unifi_access import UnifiAccessManager +from hostex_api import HostexManager +from ics_parser import ICSParser +from notification import NotificationManager +from utils import setup_logging + +def main(): + parser = argparse.ArgumentParser(description="UniFi Access Visitor Management") + parser.add_argument('-v', '--verbose', action='store_true', help="Increase output verbosity") + parser.add_argument('-l', '--log', help="Log output to file") + args = parser.parse_args() + + config = load_config() + log_file = args.log or config['log_file'] + logger = setup_logging(args.verbose, log_file) + + unifi_manager = UnifiAccessManager(config) + hostex_manager = HostexManager(config) + ics_parser = ICSParser(config) + notification_manager = NotificationManager(config) + + try: + logger.debug("Script started") + + if config['use_hostex']: + reservations = hostex_manager.fetch_reservations() + elif config['use_ics']: + reservations = ics_parser.parse_ics() + else: + logger.error("No valid reservation source configured") + return + + unifi_manager.process_reservations(reservations) + + summary = unifi_manager.generate_summary() + logger.info(summary) + + if config['simplepush_enabled'] and unifi_manager.has_changes(): + notification_manager.send_notification("UniFi Access Update", summary) + + logger.debug("Script completed successfully") + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + finally: + logger.debug("Script execution finished") + +if __name__ == "__main__": + main() diff --git a/notification.py b/notification.py new file mode 100644 index 0000000..fa2ccbe --- /dev/null +++ b/notification.py @@ -0,0 +1,20 @@ +import requests +import logging + +class NotificationManager: + def __init__(self, config): + self.enabled = config['simplepush_enabled'] + self.key = config['simplepush_key'] + self.url = config['simplepush_url'] + self.logger = logging.getLogger(__name__) + + def send_notification(self, title, message, event="airbnb-access"): + if not self.enabled: + self.logger.debug("Simplepush is not enabled. Skipping notification.") + return + url = f"{self.url}/{self.key}/{title}/{message}/event/{event}" + response = requests.get(url) + if response.status_code != 200: + self.logger.error("Failed to send Simplepush notification") + else: + self.logger.debug("Simplepush notification sent successfully") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a5a29c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests==2.26.0 +icalendar==4.0.7 +urllib3==1.26.7 +configparser==5.0.2 diff --git a/unifi.conf.example b/unifi.conf.example new file mode 100644 index 0000000..cf95e30 --- /dev/null +++ b/unifi.conf.example @@ -0,0 +1,26 @@ +[UniFi] +api_host = https://unifi-access-ip:12445 +api_token = your_unifi_access_api_token + +[Hostex] +api_url = https://api.hostex.io/v3 +api_key = your_hostex_api_key + +[Airbnb] +ics_url = https://www.airbnb.com/calendar/ical/your_ical_id.ics + +[Simplepush] +enabled = true +key = your_simplepush_key +url = https://api.simplepush.io/send + +[Door] +default_group_id = your_default_door_group_id + +[Visitor] +check_in_time = 16:00:00 +check_out_time = 11:00:00 + +[General] +log_file = unifi_access_airbnb.log +pin_code_digits = 4 diff --git a/unifi_access.py b/unifi_access.py new file mode 100644 index 0000000..9adf1be --- /dev/null +++ b/unifi_access.py @@ -0,0 +1,150 @@ +import requests +import datetime +import json +import logging + +class UnifiAccessManager: + def __init__(self, config): + self.api_host = config['api_host'] + self.api_token = config['api_token'] + self.default_door_group_id = config['default_door_group_id'] + self.check_in_time = datetime.time.fromisoformat(config['check_in_time']) + self.check_out_time = datetime.time.fromisoformat(config['check_out_time']) + self.pin_code_digits = config['pin_code_digits'] + self.logger = logging.getLogger(__name__) + self.changes = {'added': [], 'deleted': [], 'unchanged': []} + + def create_visitor(self, first_name, last_name, remarks, phone_number, start_time, end_time): + url = f"{self.api_host}/api/v1/developer/visitors" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + } + pin_code = phone_number[-self.pin_code_digits:] if phone_number and len(phone_number) >= self.pin_code_digits else "" + data = { + "first_name": first_name, + "last_name": last_name, + "remarks": remarks, + "mobile_phone": phone_number, + "email": "", + "visitor_company": "", + "start_time": start_time, + "end_time": end_time, + "visit_reason": "Other", + "resources": [ + {"id": self.default_door_group_id, "type": "door_group"} + ] + } + response = requests.post(url, json=data, headers=headers, verify=False) + if response.status_code != 200: + self.logger.error(f"Failed to create visitor account for {first_name} {last_name}") + return False + else: + visitor_id = response.json()["data"]["id"] + return self.assign_pin_to_visitor(visitor_id, pin_code) + + def assign_pin_to_visitor(self, visitor_id, pin_code): + url = f"{self.api_host}/api/v1/developer/visitors/{visitor_id}/pin_codes" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + } + data = { + "pin_code": pin_code + } + response = requests.put(url, json=data, headers=headers, verify=False) + if response.status_code != 200: + self.logger.error(f"Failed to assign PIN code to visitor: {visitor_id}") + return False + else: + return True + + def fetch_visitors(self): + url = f"{self.api_host}/api/v1/developer/visitors" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + } + response = requests.get(url, headers=headers, verify=False) + if response.status_code == 200: + return response.json()["data"] + else: + self.logger.error("Failed to fetch existing visitors") + return [] + + def delete_visitor(self, visitor_id, is_completed=False): + url = f"{self.api_host}/api/v1/developer/visitors/{visitor_id}" + headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + } + params = {"is_force": "true"} if is_completed else {} + + response = requests.delete(url, headers=headers, params=params, verify=False) + if response.status_code != 200: + self.logger.error(f"Failed to delete visitor account: {visitor_id}") + return False + else: + return True + + def is_pin_in_use(self, pin_code): + visitors = self.fetch_visitors() + for visitor in visitors: + if "pin_codes" in visitor and pin_code in visitor["pin_codes"]: + return True + return False + + def process_reservations(self, reservations): + today = datetime.date.today() + next_month = today + datetime.timedelta(days=30) + existing_visitors = self.fetch_visitors() + + for reservation in reservations: + check_in_date = datetime.datetime.strptime(reservation["check_in_date"], "%Y-%m-%d").date() + check_out_date = datetime.datetime.strptime(reservation["check_out_date"], "%Y-%m-%d").date() + + if today <= check_in_date <= next_month and reservation["status"] == "accepted": + guest_name = reservation["guests"][0]["name"] if reservation["guests"] else "Guest" + remarks = json.dumps(reservation) + phone_number = reservation["guests"][0].get("phone", "") if reservation["guests"] else "" + + existing_visitor = next( + (v for v in existing_visitors if + datetime.datetime.fromtimestamp(v["start_time"]).date() == check_in_date and + datetime.datetime.fromtimestamp(v["end_time"]).date() == check_out_date), + None + ) + + if existing_visitor: + self.changes['unchanged'].append(f"{existing_visitor['first_name']} {existing_visitor['last_name']}") + else: + start_datetime = datetime.datetime.combine(check_in_date, self.check_in_time) + end_datetime = datetime.datetime.combine(check_out_date, self.check_out_time) + start_timestamp = int(start_datetime.timestamp()) + end_timestamp = int(end_datetime.timestamp()) + + success = self.create_visitor(guest_name, "", remarks, phone_number, start_timestamp, end_timestamp) + if success: + self.changes['added'].append(guest_name) + else: + self.logger.error(f"Failed to create visitor: {guest_name}") + + for visitor in existing_visitors: + visitor_end = datetime.datetime.fromtimestamp(visitor["end_time"]).date() + is_completed = visitor.get("status") == "VISITED" + if visitor_end < today or is_completed: + success = self.delete_visitor(visitor["id"], is_completed) + if success: + self.changes['deleted'].append(f"{visitor['first_name']} {visitor['last_name']}") + else: + self.logger.error(f"Failed to delete visitor: {visitor['first_name']} {visitor['last_name']}") + + def generate_summary(self): + summary = "UniFi Access Update Summary:\n" + summary += f"{len(self.changes['unchanged'])} existing visitors unchanged\n" + summary += f"{len(self.changes['deleted'])} visitor(s) deleted\n" + summary += f"{len(self.changes['added'])} visitor(s) added\n" + return summary + + def has_changes(self): + return bool(self.changes['added'] or self.changes['deleted']) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..deadb0b --- /dev/null +++ b/utils.py @@ -0,0 +1,19 @@ +import logging + +def setup_logging(verbose, log_file): + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG if verbose else logging.INFO) + ch.setFormatter(formatter) + logger.addHandler(ch) + + fh = logging.FileHandler(log_file) + fh.setLevel(logging.DEBUG) + fh.setFormatter(formatter) + logger.addHandler(fh) + + return logger