├── .gitignore ├── requirements.txt ├── utils.py ├── unifi.conf.example ├── CHANGELOG.md ├── notification.py ├── hostex_api.py ├── LICENSE ├── ics_parser.py ├── config.py ├── README.md ├── main.py └── unifi_access.py /.gitignore: -------------------------------------------------------------------------------- 1 | unifi.conf 2 | *.log 3 | __pycache__/ 4 | *.pyc 5 | .vscode/ 6 | .idea/ 7 | *.swp 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.26.0 2 | icalendar==4.0.7 3 | urllib3==1.26.7 4 | configparser==5.0.2 5 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def setup_logging(verbose, log_file): 4 | logger = logging.getLogger() 5 | logger.setLevel(logging.DEBUG) 6 | 7 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 8 | 9 | ch = logging.StreamHandler() 10 | ch.setLevel(logging.DEBUG if verbose else logging.INFO) 11 | ch.setFormatter(formatter) 12 | logger.addHandler(ch) 13 | 14 | fh = logging.FileHandler(log_file) 15 | fh.setLevel(logging.DEBUG) 16 | fh.setFormatter(formatter) 17 | logger.addHandler(fh) 18 | 19 | return logger 20 | -------------------------------------------------------------------------------- /unifi.conf.example: -------------------------------------------------------------------------------- 1 | [UniFi] 2 | api_host = https://unifi-access-ip:12445 3 | api_token = your_unifi_access_api_token 4 | 5 | [Hostex] 6 | api_url = https://api.hostex.io/v3 7 | api_key = your_hostex_api_key 8 | 9 | [Airbnb] 10 | ics_url = https://www.airbnb.com/calendar/ical/your_ical_id.ics 11 | 12 | [Simplepush] 13 | enabled = true 14 | key = your_simplepush_key 15 | url = https://api.simplepush.io/send 16 | 17 | [Door] 18 | default_group_id = your_default_door_group_id 19 | 20 | [Visitor] 21 | check_in_time = 16:00:00 22 | check_out_time = 11:00:00 23 | 24 | [General] 25 | log_file = unifi_access_airbnb.log 26 | pin_code_digits = 4 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] 6 | 7 | ## [0.2.0] - 2024-09-11 8 | 9 | ### Added 10 | - Automatic setting of default door group when only one is available 11 | - Improved error logging for API interactions 12 | - CHANGELOG file to track project changes 13 | 14 | ### Changed 15 | - Refactored `create_visitor` method to simplify API calls 16 | - Updated `process_reservations` method to use new `create_visitor` implementation 17 | - Improved error handling in API request methods 18 | 19 | ### Fixed 20 | - Issue with visitor creation failing due to invalid parameters 21 | 22 | ### Updated 23 | - README with more detailed usage instructions and configuration details 24 | -------------------------------------------------------------------------------- /notification.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | 4 | class NotificationManager: 5 | def __init__(self, config): 6 | self.enabled = config['simplepush_enabled'] 7 | self.key = config['simplepush_key'] 8 | self.url = config['simplepush_url'] 9 | self.logger = logging.getLogger(__name__) 10 | 11 | def send_notification(self, title, message, event="airbnb-access"): 12 | if not self.enabled: 13 | self.logger.debug("Simplepush is not enabled. Skipping notification.") 14 | return 15 | url = f"{self.url}/{self.key}/{title}/{message}/event/{event}" 16 | response = requests.get(url) 17 | if response.status_code != 200: 18 | self.logger.error("Failed to send Simplepush notification") 19 | else: 20 | self.logger.debug("Simplepush notification sent successfully") 21 | -------------------------------------------------------------------------------- /hostex_api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | 4 | class HostexManager: 5 | def __init__(self, config): 6 | self.api_url = config['hostex_api_url'] 7 | self.api_key = config['hostex_api_key'] 8 | self.logger = logging.getLogger(__name__) 9 | 10 | def fetch_reservations(self): 11 | url = f"{self.api_url}/reservations?limit=100" 12 | headers = { 13 | "Authorization": f"Bearer {self.api_key}", 14 | "Content-Type": "application/json" 15 | } 16 | response = requests.get(url, headers=headers) 17 | if response.status_code == 200: 18 | reservations = response.json()["data"]["reservations"] 19 | self.logger.debug(f"Fetched {len(reservations)} reservations from Hostex") 20 | return reservations 21 | else: 22 | self.logger.error(f"Failed to fetch reservations from Hostex. Status code: {response.status_code}") 23 | return [] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Keith Herrington 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ics_parser.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import icalendar 3 | import datetime 4 | import logging 5 | 6 | class ICSParser: 7 | def __init__(self, config): 8 | self.ics_url = config['ics_url'] 9 | self.logger = logging.getLogger(__name__) 10 | 11 | def parse_ics(self): 12 | response = requests.get(self.ics_url) 13 | cal = icalendar.Calendar.from_ical(response.text) 14 | reservations = [] 15 | for event in cal.walk("VEVENT"): 16 | start = event.get("DTSTART").dt 17 | end = event.get("DTEND").dt 18 | description = event.get("DESCRIPTION", "") 19 | if not description: 20 | start_date = start if isinstance(start, datetime.date) else start.date() 21 | self.logger.debug(f"Skipping event with start date {start_date} due to missing description") 22 | continue 23 | pin_code = "" 24 | for line in description.split("\n"): 25 | if line.startswith("Phone Number (Last 4 Digits):"): 26 | pin_code = line.split(": ")[1].strip() 27 | break 28 | reservations.append({ 29 | "check_in_date": start if isinstance(start, datetime.date) else start.date(), 30 | "check_out_date": end if isinstance(end, datetime.date) else end.date(), 31 | "guests": [{"name": "Airbnb Guest", "phone": pin_code}], 32 | "status": "accepted" 33 | }) 34 | self.logger.debug(f"Parsed {len(reservations)} reservations from ICS file") 35 | return reservations 36 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | def load_config(): 7 | config = configparser.ConfigParser() 8 | config.read('unifi.conf') 9 | 10 | logger.debug("Loaded sections from unifi.conf: %s", config.sections()) 11 | 12 | if 'Door' not in config: 13 | logger.error("'Door' section not found in unifi.conf") 14 | elif 'default_group_id' not in config['Door']: 15 | logger.error("'default_group_id' not found in 'Door' section of unifi.conf") 16 | else: 17 | logger.debug("Found default_group_id in config: %s", config['Door']['default_group_id']) 18 | 19 | return { 20 | 'api_host': config['UniFi']['api_host'], 21 | 'api_token': config['UniFi']['api_token'], 22 | 'hostex_api_url': config['Hostex']['api_url'], 23 | 'hostex_api_key': config['Hostex']['api_key'], 24 | 'ics_url': config.get('Airbnb', 'ics_url', fallback=None), 25 | 'simplepush_enabled': config['Simplepush'].getboolean('enabled', fallback=False), 26 | 'simplepush_key': config['Simplepush'].get('key', fallback=None), 27 | 'simplepush_url': config['Simplepush'].get('url', fallback=None), 28 | 'default_door_group_id': config['Door'].get('default_group_id', ''), 29 | 'check_in_time': config['Visitor']['check_in_time'], 30 | 'check_out_time': config['Visitor']['check_out_time'], 31 | 'use_hostex': 'Hostex' in config and config['Hostex']['api_key'], 32 | 'use_ics': config.get('Airbnb', 'ics_url', fallback=None) is not None, 33 | 'log_file': config['General']['log_file'], 34 | 'pin_code_digits': int(config['General']['pin_code_digits']) 35 | } 36 | 37 | logger.debug("Loaded configuration: %s", {k: v for k, v in config.items() if k != 'api_token'}) 38 | return config 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project integrates UniFi Access with Airbnb reservations, automating the process of creating and managing visitor access for your Airbnb guests. 2 | 3 | ## Features 4 | - Fetch reservations from Hostex API or Airbnb ICS feed 5 | - Create UniFi Access visitor accounts for upcoming guests 6 | - Assign PIN codes to visitors based on their phone number 7 | - Automatically delete past or completed visitor accounts 8 | - Send notifications via Simplepush for updates and failures 9 | - Cross-verify reservations between Hostex and ICS calendar 10 | - Monitor and report discrepancies in booking data 11 | - Detailed logging of all visitor management operations 12 | 13 | ## Prerequisites 14 | - Python 3.7+ 15 | - UniFi Access system 16 | - Airbnb account with ICS feed URL or Hostex API access 17 | - [Optional] Simplepush account for notifications 18 | 19 | ## Installation 20 | 1. Clone the repository: 21 | 22 | git clone https://github.com/keithah/unifi-access-airbnb.git 23 | 24 | cd unifi-access-airbnb 25 | 26 | 2. Install the required packages: 27 | 28 | 29 | pip install -r requirements.txt 30 | 31 | 3. Copy the example configuration file and edit it with your settings: 32 | 33 | cp unifi.conf.example unifi.conf 34 | 35 | nano unifi.conf 36 | 37 | ## Usage 38 | Run the script using: 39 | 40 | python3 main.py 41 | 42 | Optional arguments: 43 | - `-v` or `--verbose`: Increase output verbosity 44 | - `-l [LOG_FILE]` or `--log [LOG_FILE]`: Specify a log file (default: unifi_access.log) 45 | - `--list-door-groups`: List available door groups 46 | 47 | ## Configuration 48 | Edit the `unifi.conf` file with your specific settings. Key sections include: 49 | - `[UniFi]`: UniFi Access API settings 50 | - api_host: UniFi Access controller URL 51 | - api_token: Authentication token 52 | - `[Hostex]`: Hostex API settings (if used) 53 | - api_url: Hostex API endpoint 54 | - api_key: Authentication key 55 | - `[Airbnb]`: Airbnb ICS feed URL (if used) 56 | - ics_url: Calendar feed URL 57 | - `[Door]`: Door access settings 58 | - default_group_id: Default door group ID for visitor access 59 | - `[Visitor]`: Visit timing settings 60 | - check_in_time: Default check-in time (e.g., "14:30") 61 | - check_out_time: Default check-out time (e.g., "11:30") 62 | - `[General]`: General settings 63 | - log_file: Path to log file 64 | - pin_code_digits: Number of digits for PIN codes 65 | - `[Simplepush]`: Notification settings 66 | - enabled: Enable/disable notifications 67 | - key: Simplepush key 68 | - url: Simplepush API URL 69 | 70 | ## Logging and Monitoring 71 | The script provides detailed logging of all operations: 72 | - Reservation processing from Hostex and ICS 73 | - Visitor creation and deletion in UniFi Access 74 | - PIN code assignments and updates 75 | - Cross-system verification results 76 | - Errors and warnings 77 | 78 | Logs can be viewed in real-time using the `-v` flag or reviewed in the log file. 79 | 80 | ## System Verification 81 | The script performs several verification checks: 82 | - Matches Hostex reservations with ICS calendar entries 83 | - Verifies phone numbers and PIN codes across systems 84 | - Reports discrepancies in dates or guest information 85 | - Monitors UniFi Access visitor status 86 | 87 | ## Contributing 88 | Contributions are welcome! Please feel free to submit a Pull Request. 89 | 90 | ## License 91 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 92 | 93 | ## Support 94 | If you encounter any issues or have questions: 95 | 1. Check the logs using verbose mode 96 | 2. Review your configuration file 97 | 3. Open an issue on GitHub with relevant logs and details``` 98 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import urllib3 4 | import datetime 5 | from config import load_config 6 | from unifi_access import UnifiAccessManager 7 | from hostex_api import HostexManager 8 | from ics_parser import ICSParser 9 | from notification import NotificationManager 10 | from utils import setup_logging 11 | 12 | # Suppress InsecureRequestWarning 13 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 14 | 15 | def verify_across_systems(hostex_reservations, ics_reservations, unifi_visitors): 16 | discrepancies = [] 17 | today = datetime.date.today() 18 | next_month = today + datetime.timedelta(days=30) 19 | 20 | # Filter relevant Hostex reservations 21 | relevant_hostex = [ 22 | r for r in hostex_reservations 23 | if today <= datetime.datetime.strptime(r["check_in_date"], "%Y-%m-%d").date() <= next_month 24 | and r["status"] == "accepted" 25 | ] 26 | 27 | # Create lookup dictionaries by date range 28 | hostex_lookup = { 29 | (r["check_in_date"], r["check_out_date"]): r 30 | for r in relevant_hostex 31 | } 32 | 33 | ics_lookup = { 34 | (r["check_in_date"].strftime("%Y-%m-%d"), r["check_out_date"].strftime("%Y-%m-%d")): r 35 | for r in ics_reservations 36 | } 37 | 38 | # Check Hostex entries against ICS 39 | for dates, hostex_res in hostex_lookup.items(): 40 | if dates not in ics_lookup: 41 | discrepancies.append( 42 | f"Hostex reservation for {hostex_res['guests'][0]['name']} " 43 | f"({dates[0]} to {dates[1]}) not found in ICS calendar" 44 | ) 45 | else: 46 | # Verify phone number last 4 digits match 47 | hostex_phone = hostex_res['guests'][0].get('phone', '')[-4:] 48 | ics_phone = ics_lookup[dates]['guests'][0].get('phone', '')[-4:] 49 | if hostex_phone and ics_phone and hostex_phone != ics_phone: 50 | discrepancies.append( 51 | f"Phone number mismatch for {hostex_res['guests'][0]['name']}: " 52 | f"Hostex: {hostex_phone}, ICS: {ics_phone}" 53 | ) 54 | 55 | return discrepancies 56 | 57 | def main(): 58 | parser = argparse.ArgumentParser(description="UniFi Access Visitor Management") 59 | parser.add_argument('-v', '--verbose', action='store_true', help="Increase output verbosity") 60 | parser.add_argument('-l', '--log', help="Log output to file") 61 | parser.add_argument('--list-door-groups', action='store_true', help="List available door groups") 62 | args = parser.parse_args() 63 | 64 | # Initialize logging first 65 | logger = logging.getLogger(__name__) 66 | log_file = args.log or 'unifi_access.log' # Default log file if not specified 67 | logger = setup_logging(args.verbose, log_file) 68 | 69 | try: 70 | config = load_config() 71 | logger.debug(f"Loaded config: {config}") 72 | except Exception as e: 73 | logger.error(f"Error loading configuration: {str(e)}") 74 | return 75 | 76 | try: 77 | unifi_manager = UnifiAccessManager(config) 78 | except ValueError as e: 79 | logger.error(f"Error initializing UnifiAccessManager: {str(e)}") 80 | return 81 | 82 | if args.list_door_groups: 83 | unifi_manager.print_door_groups() 84 | return 85 | 86 | hostex_manager = HostexManager(config) 87 | ics_parser = ICSParser(config) 88 | notification_manager = NotificationManager(config) 89 | 90 | try: 91 | logger.info("Script started") 92 | 93 | if config['use_hostex']: 94 | logger.info("Fetching reservations from Hostex") 95 | hostex_reservations = hostex_manager.fetch_reservations() 96 | else: 97 | hostex_reservations = [] 98 | 99 | if config['use_ics']: 100 | logger.info("Parsing ICS file") 101 | ics_reservations = ics_parser.parse_ics() 102 | else: 103 | ics_reservations = [] 104 | 105 | # Filter and log relevant reservations 106 | today = datetime.date.today() 107 | next_month = today + datetime.timedelta(days=30) 108 | relevant_reservations = [ 109 | r for r in hostex_reservations 110 | if today <= datetime.datetime.strptime(r["check_in_date"], "%Y-%m-%d").date() <= next_month 111 | and r["status"] == "accepted" 112 | ] 113 | 114 | logger.info(f"Found {len(relevant_reservations)} entries in Hostex API within the next 30 days") 115 | 116 | for res in relevant_reservations: 117 | guest_name = res["guests"][0]["name"] if res["guests"] else "Guest" 118 | phone_number = res["guests"][0].get("phone", "") if res["guests"] else "" 119 | logger.debug( 120 | f"Hostex Guest: {guest_name}, " 121 | f"Stay: {res['check_in_date']} to {res['check_out_date']}, " 122 | f"Phone: {phone_number}" 123 | ) 124 | 125 | # Verify consistency across systems 126 | discrepancies = verify_across_systems( 127 | hostex_reservations, 128 | ics_reservations, 129 | unifi_manager.fetch_visitors() 130 | ) 131 | 132 | # Process reservations 133 | logger.info(f"Processing {len(relevant_reservations)} reservations") 134 | unifi_manager.process_reservations(relevant_reservations) 135 | 136 | logger.info("Checking and updating PINs for existing visitors") 137 | unifi_manager.check_and_update_pins() 138 | 139 | summary = unifi_manager.generate_summary() 140 | if discrepancies: 141 | summary += "\n\nDiscrepancies Found:\n" + "\n".join(discrepancies) 142 | 143 | logger.info(summary) 144 | 145 | total_visitors = len(unifi_manager.fetch_visitors()) 146 | logger.info(f"Total UniFi Access visitors remaining after cleanup: {total_visitors}") 147 | 148 | if config['simplepush_enabled'] and (unifi_manager.has_changes() or discrepancies): 149 | notification_manager.send_notification("UniFi Access Update", summary) 150 | logger.info("Simplepush notification sent") 151 | else: 152 | logger.info("No Simplepush notification sent (no changes or Simplepush not enabled)") 153 | 154 | logger.info("Script completed successfully") 155 | except Exception as e: 156 | logger.error(f"An error occurred: {str(e)}", exc_info=True) 157 | finally: 158 | logger.info("Script execution finished") 159 | 160 | if __name__ == "__main__": 161 | main() 162 | -------------------------------------------------------------------------------- /unifi_access.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import datetime 3 | import json 4 | import logging 5 | 6 | class UnifiAccessManager: 7 | def __init__(self, config): 8 | self.api_host = config['api_host'] 9 | self.api_token = config['api_token'] 10 | self.default_door_group_id = config.get('default_door_group_id', '') 11 | self.check_in_time = datetime.time.fromisoformat(config['check_in_time']) 12 | self.check_out_time = datetime.time.fromisoformat(config['check_out_time']) 13 | self.pin_code_digits = config['pin_code_digits'] 14 | self.logger = logging.getLogger(__name__) 15 | self.changes = {'added': [], 'deleted': [], 'unchanged': []} 16 | 17 | self.logger.debug(f"Loaded default_door_group_id from config: {self.default_door_group_id}") 18 | if not self.default_door_group_id: 19 | self.logger.warning("default_door_group_id is not set in config. Attempting to fetch available door groups.") 20 | self.set_default_door_group() 21 | else: 22 | self.logger.debug(f"Initialized with default_door_group_id: {self.default_door_group_id}") 23 | 24 | def set_default_door_group(self): 25 | door_groups = self.fetch_door_groups() 26 | if len(door_groups) == 1: 27 | self.default_door_group_id = door_groups[0]['id'] 28 | self.logger.info(f"Automatically set default_door_group_id to the only available group: {self.default_door_group_id}") 29 | elif len(door_groups) > 1: 30 | self.logger.error("Multiple door groups available. Please specify default_group_id in unifi.conf") 31 | raise ValueError("Multiple door groups available. Please specify default_group_id in unifi.conf") 32 | else: 33 | self.logger.error("No door groups available") 34 | raise ValueError("No door groups available") 35 | 36 | def create_visitor(self, first_name, last_name, phone_number, start_time, end_time): 37 | url = f"{self.api_host}/api/v1/developer/visitors" 38 | headers = { 39 | "Authorization": f"Bearer {self.api_token}", 40 | "Content-Type": "application/json" 41 | } 42 | pin_code = phone_number[-self.pin_code_digits:] if phone_number and len(phone_number) >= self.pin_code_digits else "" 43 | data = { 44 | "first_name": first_name, 45 | "last_name": last_name, 46 | "mobile_phone": phone_number, 47 | "start_time": start_time, 48 | "end_time": end_time, 49 | "visit_reason": "Other", 50 | "resources": [ 51 | { 52 | "id": self.default_door_group_id, 53 | "type": "door_group" 54 | } 55 | ] 56 | } 57 | 58 | self.logger.debug(f"Creating visitor with data: {json.dumps(data, indent=2)}") 59 | 60 | try: 61 | response = requests.post(url, json=data, headers=headers, verify=False) 62 | self.logger.debug(f"API response status code: {response.status_code}") 63 | self.logger.debug(f"API response content: {response.text}") 64 | 65 | response.raise_for_status() 66 | 67 | response_data = response.json() 68 | if response_data.get('code') == 'SUCCESS': 69 | visitor_id = response_data.get('data', {}).get('id') 70 | if visitor_id: 71 | self.logger.debug(f"Created visitor with ID: {visitor_id}") 72 | if pin_code: 73 | self.assign_pin_to_visitor(visitor_id, pin_code) 74 | return True 75 | else: 76 | self.logger.error("Visitor ID not found in the response") 77 | return False 78 | else: 79 | self.logger.error(f"API returned an error: {response_data.get('msg')}") 80 | return False 81 | except requests.exceptions.RequestException as e: 82 | self.logger.error(f"Request failed: {str(e)}") 83 | return False 84 | 85 | def assign_pin_to_visitor(self, visitor_id, pin_code): 86 | url = f"{self.api_host}/api/v1/developer/visitors/{visitor_id}/pin_codes" 87 | headers = { 88 | "Authorization": f"Bearer {self.api_token}", 89 | "Content-Type": "application/json" 90 | } 91 | data = {"pin_code": pin_code} 92 | 93 | self.logger.debug(f"Assigning PIN {pin_code} to visitor {visitor_id}") 94 | self.logger.debug(f"Request URL: {url}") 95 | self.logger.debug(f"Request data: {json.dumps(data)}") 96 | 97 | response = requests.put(url, json=data, headers=headers, verify=False) 98 | self.logger.debug(f"Assign PIN API response status code: {response.status_code}") 99 | self.logger.debug(f"Assign PIN API response content: {response.text}") 100 | 101 | if response.status_code != 200: 102 | self.logger.error(f"Failed to assign PIN code to visitor: {visitor_id}") 103 | return False 104 | return True 105 | 106 | def fetch_visitors(self): 107 | url = f"{self.api_host}/api/v1/developer/visitors" 108 | headers = { 109 | "Authorization": f"Bearer {self.api_token}", 110 | "Content-Type": "application/json" 111 | } 112 | response = requests.get(url, headers=headers, verify=False) 113 | self.logger.debug(f"Fetch visitors API response status code: {response.status_code}") 114 | 115 | if response.status_code == 200: 116 | data = response.json() 117 | if 'data' in data: 118 | visitors = data['data'] 119 | self.logger.debug(f"Fetched {len(visitors)} visitors") 120 | for visitor in visitors: 121 | self.logger.debug(f"Visitor: {visitor['first_name']} {visitor['last_name']}, Phone: {visitor.get('mobile_phone', 'N/A')}, PIN: {'Set' if visitor.get('pin_code') else 'Not Set'}") 122 | return visitors 123 | else: 124 | self.logger.error(f"Unexpected response format: {data}") 125 | return [] 126 | else: 127 | self.logger.error("Failed to fetch existing visitors") 128 | return [] 129 | 130 | def delete_visitor(self, visitor_id, is_completed=False): 131 | url = f"{self.api_host}/api/v1/developer/visitors/{visitor_id}" 132 | headers = { 133 | "Authorization": f"Bearer {self.api_token}", 134 | "Content-Type": "application/json" 135 | } 136 | params = {"is_force": "true"} if is_completed else {} 137 | 138 | response = requests.delete(url, headers=headers, params=params, verify=False) 139 | self.logger.debug(f"Delete visitor API response status code: {response.status_code}") 140 | 141 | if response.status_code != 200: 142 | self.logger.error(f"Failed to delete visitor account: {visitor_id}") 143 | return False 144 | return True 145 | 146 | def process_reservations(self, reservations): 147 | today = datetime.date.today() 148 | next_month = today + datetime.timedelta(days=30) 149 | existing_visitors = self.fetch_visitors() 150 | 151 | self.logger.debug(f"Processing {len(reservations)} reservations") 152 | 153 | for reservation in reservations: 154 | check_in_date = datetime.datetime.strptime(reservation["check_in_date"], "%Y-%m-%d").date() 155 | check_out_date = datetime.datetime.strptime(reservation["check_out_date"], "%Y-%m-%d").date() 156 | 157 | if today <= check_in_date <= next_month and reservation["status"] == "accepted": 158 | guest_name = reservation["guests"][0]["name"] if reservation["guests"] else "Guest" 159 | first_name, last_name = guest_name.split(" ", 1) if " " in guest_name else (guest_name, "") 160 | phone_number = reservation["guests"][0].get("phone", "") if reservation["guests"] else "" 161 | 162 | existing_visitor = next( 163 | (v for v in existing_visitors if 164 | datetime.datetime.fromtimestamp(int(v["start_time"])).date() == check_in_date and 165 | datetime.datetime.fromtimestamp(int(v["end_time"])).date() == check_out_date), 166 | None 167 | ) 168 | 169 | if existing_visitor: 170 | self.logger.debug(f"Visitor already exists for dates {check_in_date} to {check_out_date}: {existing_visitor['first_name']} {existing_visitor['last_name']}") 171 | self.changes['unchanged'].append(f"{existing_visitor['first_name']} {existing_visitor['last_name']}") 172 | if not existing_visitor.get('pin_code') and phone_number: 173 | self.assign_pin_to_visitor(existing_visitor['id'], phone_number[-self.pin_code_digits:]) 174 | else: 175 | start_datetime = datetime.datetime.combine(check_in_date, self.check_in_time) 176 | end_datetime = datetime.datetime.combine(check_out_date, self.check_out_time) 177 | start_timestamp = int(start_datetime.timestamp()) 178 | end_timestamp = int(end_datetime.timestamp()) 179 | 180 | success = self.create_visitor(first_name, last_name, phone_number, start_timestamp, end_timestamp) 181 | if success: 182 | self.changes['added'].append(guest_name) 183 | self.logger.info(f"Created new visitor: {guest_name}") 184 | else: 185 | self.logger.error(f"Failed to create visitor: {guest_name}") 186 | 187 | for visitor in existing_visitors: 188 | visitor_end = datetime.datetime.fromtimestamp(int(visitor["end_time"])).date() 189 | is_completed = visitor.get("status") == "VISITED" 190 | if visitor_end < today or is_completed: 191 | success = self.delete_visitor(visitor["id"], is_completed) 192 | if success: 193 | self.changes['deleted'].append(f"{visitor['first_name']} {visitor['last_name']}") 194 | self.logger.info(f"Deleted visitor: {visitor['first_name']} {visitor['last_name']}") 195 | else: 196 | self.logger.error(f"Failed to delete visitor: {visitor['first_name']} {visitor['last_name']}") 197 | 198 | def check_and_update_pins(self): 199 | visitors = self.fetch_visitors() 200 | self.logger.debug(f"Checking PINs for {len(visitors)} visitors") 201 | for visitor in visitors: 202 | self.logger.debug(f"Checking visitor: {visitor['first_name']} {visitor['last_name']}") 203 | if 'pin_code' not in visitor or not visitor['pin_code']: 204 | self.logger.debug(f"Visitor {visitor['first_name']} {visitor['last_name']} has no PIN") 205 | phone_number = visitor.get('mobile_phone', '') 206 | self.logger.debug(f"Visitor phone number: {phone_number}") 207 | pin_code = phone_number[-self.pin_code_digits:] if phone_number and len(phone_number) >= self.pin_code_digits else "" 208 | if pin_code: 209 | self.logger.debug(f"Attempting to set PIN {pin_code} for visitor {visitor['id']}") 210 | success = self.assign_pin_to_visitor(visitor['id'], pin_code) 211 | if success: 212 | self.logger.info(f"Updated PIN for visitor: {visitor['first_name']} {visitor['last_name']}") 213 | else: 214 | self.logger.error(f"Failed to update PIN for visitor: {visitor['first_name']} {visitor['last_name']}") 215 | else: 216 | self.logger.warning(f"No valid phone number to generate PIN for visitor: {visitor['first_name']} {visitor['last_name']}") 217 | else: 218 | self.logger.debug(f"Visitor {visitor['first_name']} {visitor['last_name']} already has a PIN") 219 | 220 | def generate_summary(self): 221 | summary = "Hostex-UniFi Access Summary:\n" 222 | unchanged_names = ", ".join(self.changes['unchanged']) 223 | summary += f"{len(self.changes['unchanged'])} existing UniFi Access visitors unchanged ({unchanged_names})\n" 224 | if self.changes['deleted']: 225 | deleted_names = ", ".join(self.changes['deleted']) 226 | summary += f"{len(self.changes['deleted'])} UniFi Access visitor(s) deleted ({deleted_names})\n" 227 | if self.changes['added']: 228 | added_names = ", ".join(self.changes['added']) 229 | summary += f"{len(self.changes['added'])} UniFi Access visitor(s) added ({added_names})\n" 230 | return summary.strip() 231 | 232 | def has_changes(self): 233 | return bool(self.changes['added'] or self.changes['deleted']) 234 | 235 | def fetch_door_groups(self): 236 | url = f"{self.api_host}/api/v1/developer/door_groups" 237 | headers = { 238 | "Authorization": f"Bearer {self.api_token}", 239 | "Content-Type": "application/json" 240 | } 241 | response = requests.get(url, headers=headers, verify=False) 242 | self.logger.debug(f"Fetch door groups API response status code: {response.status_code}") 243 | 244 | if response.status_code == 200: 245 | data = response.json() 246 | if 'data' in data: 247 | return data['data'] 248 | else: 249 | self.logger.error(f"Unexpected response format: {data}") 250 | return [] 251 | else: 252 | self.logger.error("Failed to fetch door groups") 253 | return [] 254 | 255 | def print_door_groups(self): 256 | door_groups = self.fetch_door_groups() 257 | if door_groups: 258 | print("Available Door Groups:") 259 | for group in door_groups: 260 | print(f"ID: {group['id']}, Name: {group['name']}") 261 | else: 262 | print("No door groups found or failed to fetch door groups.") 263 | --------------------------------------------------------------------------------