Repository: craigjmidwinter/total-connect-client (language: python)

Issue:
link to PyPI?
There's no link to https://pypi.org/project/total-connect-client/ on the github homepage. Would it be good to add one?
Issue #2: Adding the ability to retrieve zone statuses
This PR resolves issue #2 by adding the ability to get the status of all zones in a given location.


Discussion / hints:

Awesome! Thanks! Will update pypi with both of these when I get home

Is it planned to update HomeAssistant to have sensors for Zone Status?  (Maybe this already exists, I can't find it in the documentation)
@SP410 it hasn't been added yet.  I'll try to get to it soon.  

Fix patch:
diff --git a/ALARM_STATUS.md b/ALARM_STATUS.md
index 77f8136..05a6cc7 100644
--- a/ALARM_STATUS.md
+++ b/ALARM_STATUS.md
@@ -20,6 +20,7 @@ Code | Status
 10211 | Disarmed bypass
 10212 | Alarming Fire/Smoke. This code is reported for smoke detectors.  It is also reported for manually pushed buttons on the panel for Fire.
 10213 | Alarming Carbon Monoxide
+10214 | UNKNOWN - reported by Pro7A panel
 10218 | Armed Stay Night
 10223 | Armed custom bypass
 10307 | Arming
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e21e221
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Change Log
+
+## Version 0.60
+
+- Added multi-partition support via the new Partition class
+- Added new zone_type_id's from ProA7 panel
+- Added Device class
+
+
diff --git a/DEVICES.md b/DEVICES.md
index e781c38..9645ec3 100644
--- a/DEVICES.md
+++ b/DEVICES.md
@@ -6,4 +6,5 @@ Real Device | DeviceName | DeviceClassID | PanelType | PanelVariant | SecurityPa
 ------------ | - | - | - | - | - | -
 Lynx Touch 7000 | ILP5 | 1 | 8 | 0 | None |
 Skybell HD | WiFi DoorBell | 7 | not returned | not returned | None | 
-Vista-21IP | Security Panel | 1 | 2 | 1 | None | #36
\ No newline at end of file
+Vista-21IP | Security Panel | 1 | 2 | 1 | None | #36
+ProA7Plus | Security System | 1 | 12 | 1 | None | 
\ No newline at end of file
diff --git a/README.md b/README.md
index b55335e..91e9da7 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,8 @@ Total-Connect-Client is a super simple python client for interacting with TotalC
 
 Started by @craigjmidwinter to add alarm support for his personal HomeAssistant set-up, with later contributions from others.
 
+The package can be downloaded at [PyPI](https://pypi.org/project/total-connect-client/).
+
 The code currently supports:
  - Arming (away, stay, night)
  - Disarming
diff --git a/RESULT_CODES.md b/RESULT_CODES.md
index dde7677..c70b6a7 100644
--- a/RESULT_CODES.md
+++ b/RESULT_CODES.md
@@ -12,6 +12,7 @@ ResultCode | ResultData | Notes
 -100 | Authentication failed
 -102 | INVALID_SESSION |  
 -120 | Feature Not Supported | When calling get_zone_details()
+-400 | Object reference not set to an instance of an object. | GetPanelMetaDataAndFullStatusEx_V2 without partition list
 -4002 | The specified location is not valid |
 -4007 | NoAutomationDeviceFoundAtSpecifiedLocationException |
 -4502 | Command failed. Please try again. | Trying to arm system with zone faulted. 
diff --git a/ZONE_TYPES.md b/ZONE_TYPES.md
index a743baf..0bc277d 100644
--- a/ZONE_TYPES.md
+++ b/ZONE_TYPES.md
@@ -6,9 +6,13 @@ Zone Type # | Type | Notes
 ------------ | - | -
 0 | Security | Lynx Touch 7000 and sensors 5820L, 5818MNL, 5800RPS, 5898
 1 | Security | Lyric and 5800MINI
+3 | Security | 5800mini via the ProA7Plus "takeover" module
 4 | Security | Lyric and SiXPIR motion
 6 | tbd | Lyric "Police", perhaps a button?
+7 | Police | ProA7Plus police button
 9 | Fire/Smoke | Lynx Touch 7000 and 5808W3 smoke detectors.  Lynx Touch 7000 Fire button.  
-12 | tbd | Lyric "Temperature"
+10 | tbd | ProSixMini2 set to Interior with Delay via ProA7Plus panel
+12 | tbd | Lyric "Temperature", ProSixFlood via ProA7Plus panel
 14 | Carbon Monoxide | Lynx Touch 7000 and 5800CO sensors
+15 | Medical | ProA7Plus medical button
 89 | tbd | Lyric "local alarm"
diff --git a/setup.py b/setup.py
index 75daea0..68c7650 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
 setup(
     name="total_connect_client",
     py_modules=["total_connect_client"],
-    version="0.58",
+    version="2021.07",
     description="Interact with Total Connect 2 alarm systems",
     author="Craig J. Midwinter",
     author_email="craig.j.midwinter@gmail.com",
diff --git a/total_connect_client/TotalConnectClient.py b/total_connect_client/TotalConnectClient.py
index 0ab755c..af7e117 100644
--- a/total_connect_client/TotalConnectClient.py
+++ b/total_connect_client/TotalConnectClient.py
@@ -1,919 +1,459 @@
-"""Total Connect Client."""
-
-import logging
-import time
-
-import zeep
-
-ARM_TYPE_AWAY = 0
-ARM_TYPE_STAY = 1
-ARM_TYPE_STAY_INSTANT = 2
-ARM_TYPE_AWAY_INSTANT = 3
-ARM_TYPE_STAY_NIGHT = 4
-
-ZONE_STATUS_NORMAL = 0
-ZONE_STATUS_BYPASSED = 1
-ZONE_STATUS_FAULT = 2
-ZONE_STATUS_TROUBLE = 8  # is also Tampered
-ZONE_STATUS_LOW_BATTERY = 64
-ZONE_STATUS_TRIGGERED = 256
-
-ZONE_TYPE_SECURITY = 0
-ZONE_TYPE_LYRIC_CONTACT = 1
-ZONE_TYPE_LYRIC_MOTION = 4
-ZONE_TYPE_LYRIC_POLICE = 6
-ZONE_TYPE_FIRE_SMOKE = 9
-ZONE_TYPE_LYRIC_TEMP = 12
-ZONE_TYPE_CARBON_MONOXIDE = 14
-ZONE_TYPE_LYRIC_LOCAL_ALARM = 89
-
-ZONE_BYPASS_SUCCESS = 0
-GET_ALL_SENSORS_MASK_STATUS_SUCCESS = 0
-
-DEFAULT_USERCODE = "-1"
-
-
-class AuthenticationError(Exception):
-    """Authentication Error class."""
-
-    def __init__(self, *args, **kwargs):
-        """Initialize."""
-        Exception.__init__(self, *args, **kwargs)
-
-
-class TotalConnectClient:
-    """Client for Total Connect."""
-
-    INVALID_SESSION = -102
-    SUCCESS = 0
-    ARM_SUCCESS = 4500
-    DISARM_SUCCESS = 4500
-    SESSION_INITIATED = 4500
-    CONNECTION_ERROR = 4101
-    FAILED_TO_CONNECT = -4104
-    USER_CODE_INVALID = -4106
-    USER_CODE_UNAVAILABLE = -4114
-    COMMAND_FAILED = -4502
-    BAD_USER_OR_PASSWORD = -50004
-    AUTHENTICATION_FAILED = -100
-    FEATURE_NOT_SUPPORTED = -120
-
-    MAX_REQUEST_ATTEMPTS = 10
-
-    def __init__(
-        self,
-        username,
-        password,
-        usercodes={"default": DEFAULT_USERCODE},
-        auto_bypass_battery=False,
-    ):
-        """Initialize."""
-        self.times = {}
-        self.time_start = time.time()
-        self.soapClient = None
-        self.soap_base = "self.soapClient.service."
-        self.soap_ready = False
-
-        self.applicationId = "14588"
-        self.applicationVersion = "1.0.34"
-        self.username = username
-        self.password = password
-
-        if not isinstance(usercodes, dict):
-            self.usercodes = {"default": DEFAULT_USERCODE}
-        else:
-            self.usercodes = usercodes
-
-        self.auto_bypass_low_battery = auto_bypass_battery
-        self.token = False
-        self._valid_credentials = (
-            None  # None at start, True after login, False if login fails
-        )
-        self._populated = False
-        self._module_flags = None
-        self._user = None
-        self.locations = {}
-        self.authenticate()
-        self.times["__init__"] = time.time() - self.time_start
-
-    def __str__(self):
-        """Return a text string that is printable."""
-        data = (
-            f"CLIENT\n\n"
-            f"Username: {self.username}\n"
-            f"Password: {self.password}\n"
-            f"Usercode: {self.usercodes}\n"
-            f"Auto Bypass Low Battery: {self.auto_bypass_low_battery}\n"
-            f"Valid Credentials: {self._valid_credentials}\n"
-            f"Module Flags:\n"
-        )
-
-        for key, value in self._module_flags.items():
-            data = data + f"  {key}: {value}\n"
-
-        data = data + str(self._user)
-
-        locations = f"LOCATIONS: {len(self.locations)}\n\n"
-        for location in self.locations:
-            locations = locations + str(self.locations[location])
-
-        return data + locations
-
-    def get_times(self):
-        """Return a string with times."""
-        self.times["total running time"] = time.time() - self.time_start
-        msg = "total-connect-client time info (seconds):\n"
-        for key, value in self.times.items():
-            msg = msg + f"  {key}: {value}\n"
-
-        return msg
-
-    def setup_soap(self):
-        """Set up soap for use."""
-        self.soapClient = zeep.Client("https://rs.alarmnet.com/TC21api/tc2.asmx?WSDL")
-        self.soap_ready = True
-
-    def request(self, request, attempts=0):
-        """Send a SOAP request."""
-        if self.soap_ready is False:
-            self.setup_soap()
-
-        if attempts < self.MAX_REQUEST_ATTEMPTS:
-            attempts += 1
-            response = eval(self.soap_base + request)
-
-            if response.ResultCode in (
-                self.SUCCESS,
-                self.FEATURE_NOT_SUPPORTED,
-                self.ARM_SUCCESS,
-                self.DISARM_SUCCESS,
-                self.SESSION_INITIATED,
-                self.USER_CODE_INVALID,
-            ):
-                return zeep.helpers.serialize_object(response)
-            if response.ResultCode == self.INVALID_SESSION:
-                logging.debug(
-                    f"total-connect-client invalid session (attempt number {attempts})."
-                )
-                self.token = False
-                self.authenticate()
-                return self.request(request, attempts)
-            if response.ResultCode == self.CONNECTION_ERROR:
-                logging.debug(
-                    f"total-connect-client connection error (attempt number {attempts})."
-                )
-                time.sleep(3)
-                return self.request(request, attempts)
-            if response.ResultCode == self.FAILED_TO_CONNECT:
-                logging.debug(
-                    f"total-connect-client failed to connect with security system "
-                    f"(attempt number {attempts})."
-                )
-                time.sleep(3)
-                return self.request(request, attempts)
-            if response.ResultCode in (
-                self.BAD_USER_OR_PASSWORD,
-                self.AUTHENTICATION_FAILED,
-                self.USER_CODE_UNAVAILABLE,
-            ):
-                logging.debug("total-connect-client authentication failed.")
-                return zeep.helpers.serialize_object(response)
-
-            logging.warning(
-                f"total-connect-client unknown result code "
-                f"{response.ResultCode} with message: {response.ResultData}."
-            )
-            return zeep.helpers.serialize_object(response)
-
-        raise Exception(
-            "total-connect-client could not execute request.  Maximum attempts tried."
-        )
-
-    def authenticate(self):
-        """Login to the system and populate details.  Return true if successful."""
-        start_time = time.time()
-        if self._valid_credentials is not False:
-
-            if self._populated:
-                response = self.request(
-                    "AuthenticateUserLogin(self.username, self.password, "
-                    "self.applicationId, self.applicationVersion)"
-                )
-            else:
-                # this request is very slow, so only use it when necessary
-                response = self.request(
-                    "LoginAndGetSessionDetails(self.username, self.password, "
-                    "self.applicationId, self.applicationVersion)"
-                )
-
-            if response["ResultCode"] == self.SUCCESS:
-                logging.debug("Login Successful")
-                self.token = response["SessionID"]
-                self._valid_credentials = True
-                if not self._populated:
-                    self.populate_details(response)
-                    self._populated = True
-                self.times["authenticate()"] = time.time() - start_time
-                return True
-
-            self._valid_credentials = False
-            self.token = False
-            logging.error(
-                f"Unable to authenticate with Total Connect. ResultCode: "
-                f"{response['ResultCode']}. ResultData: {response['ResultData']}"
-            )
-
-        logging.debug(
-            "total-connect-client attempting login with known bad credentials."
-        )
-        self.times["authenticate()"] = time.time() - start_time
-        return False
-
-    def validate_usercode(self, device_id, usercode):
-        """Return true if the usercode is valid for the device."""
-        response = self.request(
-            f"ValidateUserCode(self.token, " f"{device_id}, '{usercode}')"
-        )
-
-        if response["ResultCode"] == self.SUCCESS:
-            return True
-
-        if response["ResultCode"] in (
-            self.USER_CODE_INVALID,
-            self.USER_CODE_UNAVAILABLE,
-        ):
-            logging.warning(
-                f"usercode '{usercode}' " f"invalid for device {device_id}."
-            )
-            return False
-
-        logging.error(
-            f"Unknown response for validate_usercode. "
-            f"ResultCode: {response['ResultCode']}. "
-            f"ResultData: {response['ResultData']}"
-        )
-
-        return False
-
-    def is_logged_in(self):
-        """Return true if the client is logged into Total Connect service."""
-        return self.token is not False
-
-    def log_out(self):
-        """Return true on logout of Total Connect service, or if not logged in."""
-        if self.is_logged_in():
-            response = self.request("Logout(self.token)")
-
-            if response["ResultCode"] == self.SUCCESS:
-                logging.info("Logout Successful")
-                self.token = False
-                return True
-
-        return False
-
-    def is_valid_credentials(self):
-        """Return true if the credentials are known to be valid."""
-        return self._valid_credentials is True
-
-    def populate_details(self, response):
-        """Populate system details."""
-        start_time = time.time()
-        location_data = response["Locations"]["LocationInfoBasic"]
-
-        self._module_flags = dict(
-            x.split("=") for x in response["ModuleFlags"].split(",")
-        )
-
-        self._user = TotalConnectUser(response["UserInfo"])
-
-        for location in location_data:
-            location_id = location["LocationID"]
-            self.locations[location_id] = TotalConnectLocation(location, self)
-
-            # set auto_bypass
-            self.locations[location_id].auto_bypass_low_battery = self.auto_bypass_low_battery
-
-            # set the usercode for the location
-            if location_id in self.usercodes:
-                self.locations[location_id].usercode = self.usercodes[location_id]
-            elif str(location_id) in self.usercodes:
-                self.locations[location_id].usercode = self.usercodes[str(location_id)]
-            else:
-                logging.warning(f"No usercode for location {location_id}.")
-
-            self.get_zone_details(location_id)
-            self.get_panel_meta_data(location_id)
-
-        if len(self.locations) < 1:
-            Exception("No locations found!")
-
-        self.times["populate_details()"] = time.time() - start_time
-
-    def keep_alive(self):
-        """Keep the token alive to avoid server timeouts."""
-        logging.info("total-connect-client initiating Keep Alive")
-
-        response = self.soapClient.service.KeepAlive(self.token)
-
-        if response.ResultCode != self.SUCCESS:
-            self.authenticate()
-
-        return response.ResultCode
-
-    def arm_away(self, location_id):
-        """Arm the system (Away)."""
-        return self.arm(ARM_TYPE_AWAY, location_id)
-
-    def arm_stay(self, location_id):
-        """Arm the system (Stay)."""
-        return self.arm(ARM_TYPE_STAY, location_id)
-
-    def arm_stay_instant(self, location_id):
-        """Arm the system (Stay - Instant)."""
-        return self.arm(ARM_TYPE_STAY_INSTANT, location_id)
-
-    def arm_away_instant(self, location_id):
-        """Arm the system (Away - Instant)."""
-        return self.arm(ARM_TYPE_AWAY_INSTANT, location_id)
-
-    def arm_stay_night(self, location_id):
-        """Arm the system (Stay - Night)."""
-        return self.arm(ARM_TYPE_STAY_NIGHT, location_id)
-
-    def arm(self, arm_type, location_id):
-        """Arm the system. Return True if successful."""
-        result = self.request(
-            f"ArmSecuritySystem(self.token, "
-            f"{location_id}, "
-            f"{self.locations[location_id].security_device_id}, "
-            f"{arm_type}, "
-            f"'{self.locations[location_id].usercode}')"
-        )
-
-        if result["ResultCode"] in (self.ARM_SUCCESS, self.SUCCESS):
-            return True
-
-        if result["ResultCode"] == self.COMMAND_FAILED:
-            logging.warning("Could not arm system. Check if a zone is faulted.")
-            return False
-
-        if result["ResultCode"] in (self.USER_CODE_INVALID, self.USER_CODE_UNAVAILABLE):
-            logging.warning("User code is invalid.")
-            return False
-
-        logging.error(
-            f"Could not arm system. "
-            f"ResultCode: {result['ResultCode']}. "
-            f"ResultData: {result['ResultData']}"
-        )
-        return False
-
-    def arm_custom(self, arm_type, location_id):
-        """Arm custom the system.  Return true if successul."""
-        ZONE_INFO = {"ZoneID": "12", "ByPass": False, "ZoneStatus": ZONE_STATUS_NORMAL}
-
-        ZONES_LIST = {}
-        ZONES_LIST[0] = ZONE_INFO
-
-        CUSTOM_ARM_SETTINGS = {"ArmMode": "1", "ArmDelay": "5", "ZonesList": ZONES_LIST}
-
-        result = self.request(
-            f"CustomArmSecuritySystem(self.token, "
-            f"{location_id}, "
-            f"{self.locations[location_id].security_device_id}, "
-            f"{arm_type}, '{self.locations[location_id].usercode}', "
-            f"{CUSTOM_ARM_SETTINGS})"
-        )
-
-        if result["ResultCode"] != self.SUCCESS:
-            logging.error(
-                f"Could not arm custom. ResultCode: {result['ResultCode']}. "
-                f"ResultData: {result['ResultData']}"
-            )
-            return False
-
-        # remove after this is all working
-        print(
-            f"arm_custom ResultCode: {result['ResultCode']}. "
-            f"arm_custom ResultData: {result['ResultData']}"
-        )
-
-        return True
-
-    def get_custom_arm_settings(self, location_id):
-        """Get custom arm settings.  Return true if successful."""
-        result = self.request(
-            f"GetCustomArmSettings(self.token, "
-            f"{location_id}, "
-            f"{self.locations[location_id].security_device_id})"
-        )
-
-        if result["ResultCode"] != self.SUCCESS:
-            logging.error(
-                f"Could not arm custom. ResultCode: {result['ResultCode']}. "
-                f"ResultData: {result['ResultData']}"
-            )
-            return False
-
-        return result
-
-    def get_panel_meta_data(self, location_id):
-        """Get all meta data about the alarm panel."""
-        start_time = time.time()
-        result = self.request(
-            f"GetPanelMetaDataAndFullStatus(self.token, {location_id}, 0, 0, 1)"
-        )
-
-        if result["ResultCode"] != self.SUCCESS:
-            logging.error(
-                f"Could not retrieve panel meta data. "
-                f"ResultCode: {result['ResultCode']}. ResultData: {result['ResultData']}"
-            )
-            return False
-
-        if "PanelMetadataAndStatus" in result:
-            status = self.locations[location_id].set_status(
-                result["PanelMetadataAndStatus"]
-            )
-            self.times[f"get_panel_meta_data({location_id})"] = time.time() - start_time
-            return status
-        else:
-            logging.warning("Panel_meta_data is empty.")
-
-        self.times[f"get_panel_meta_data({location_id})"] = time.time() - start_time
-        return result
-
-    def zone_status(self, location_id, zone_id):
-        """Get status of a zone."""
-        z = self.locations[location_id].zones.get(zone_id)
-        if z is None:
-            logging.error(f"Zone {zone_id} does not exist.")
-            return None
-
-        return z.status
-
-    def get_armed_status(self, location_id):
-        """Get the status of the panel."""
-        self.get_panel_meta_data(location_id)
-        return self.locations[location_id].arming_state
-
-    def disarm(self, location_id):
-        """Disarm the system. Return True if successful."""
-        result = self.request(
-            f"DisarmSecuritySystem(self.token, "
-            f"{location_id}, "
-            f"{self.locations[location_id].security_device_id}, "
-            f"'{self.locations[location_id].usercode}')"
-        )
-
-        if result["ResultCode"] in (self.DISARM_SUCCESS, self.SUCCESS):
-            logging.info("System Disarmed")
-            return True
-
-        if result["ResultCode"] in (self.USER_CODE_INVALID, self.USER_CODE_UNAVAILABLE):
-            logging.warning("User code is invalid.")
-            return False
-
-        logging.error(
-            f"Could not disarm system. "
-            f"ResultCode: {result['ResultCode']}. "
-            f"ResultData: {result['ResultData']}"
-        )
-        return False
-
-    def zone_bypass(self, zone_id, location_id):
-        """Bypass a zone.  Return true if successful."""
-        result = self.request(
-            f"Bypass(self.token, "
-            f"{location_id}, "
-            f"{self.locations[location_id].security_device_id}, "
-            f"{zone_id}, "
-            f"'{self.locations[location_id].usercode}')"
-        )
-
-        if result["ResultCode"] is ZONE_BYPASS_SUCCESS:
-            self.locations[location_id].zones[zone_id].bypass()
-            return True
-
-        logging.error(
-            f"Could not bypass zone {zone_id} at location {location_id}."
-            f"ResultCode: {result['ResultCode']}. "
-            f"ResultData: {result['ResultData']}"
-        )
-        return False
-
-    def get_zone_details(self, location_id):
-        """Get Zone details. Return True if successful."""
-        start_time = time.time()
-        result = self.request(
-            "GetZonesListInStateEx_V1(self.token, "
-            + str(location_id)
-            + ', {"int": ["1"]}, 0)'
-        )
-
-        if result["ResultCode"] == self.FEATURE_NOT_SUPPORTED:
-            logging.warning(
-                "Getting Zone Details is a feature not supported by "
-                "your Total Connect account or hardware."
-            )
-            self.times[f"get_zone_details({location_id})"] = time.time() - start_time
-            return False
-
-        if result["ResultCode"] != self.SUCCESS:
-            logging.error(
-                f"Could not get zone details. "
-                f"ResultCode: {result['ResultCode']}. ResultData: {result['ResultData']}."
-            )
-            self.times[f"get_zone_details({location_id})"] = time.time() - start_time
-            return False
-
-        zone_status = result.get("ZoneStatus")
-        if zone_status is not None:
-            self.times[f"get_zone_details({location_id})"] = time.time() - start_time
-            return self.locations[location_id].set_zone_details(zone_status)
-
-        logging.error(
-            f"Could not get zone details. "
-            f"ResultCode: {result['ResultCode']}. ResultData: {result['ResultData']}."
-        )
-        self.times[f"get_zone_details({location_id})"] = time.time() - start_time
-        return False
-
-
-class TotalConnectLocation:
-    """TotalConnectLocation class."""
-
-    DISARMED = 10200
-    DISARMED_BYPASS = 10211
-    ARMED_AWAY = 10201
-    ARMED_AWAY_BYPASS = 10202
-    ARMED_AWAY_INSTANT = 10205
-    ARMED_AWAY_INSTANT_BYPASS = 10206
-    ARMED_CUSTOM_BYPASS = 10223
-    ARMED_STAY = 10203
-    ARMED_STAY_BYPASS = 10204
-    ARMED_STAY_INSTANT = 10209
-    ARMED_STAY_INSTANT_BYPASS = 10210
-    ARMED_STAY_NIGHT = 10218
-    ARMING = 10307
-    DISARMING = 10308
-    ALARMING = 10207
-    ALARMING_FIRE_SMOKE = 10212
-    ALARMING_CARBON_MONOXIDE = 10213
-
-    def __init__(self, location_info_basic, parent):
-        """Initialize based on a 'LocationInfoBasic'."""
-        self.location_id = location_info_basic["LocationID"]
-        self.location_name = location_info_basic["LocationName"]
-        self._photo_url = location_info_basic["PhotoURL"]
-        self._module_flags = dict(
-            x.split("=") for x in location_info_basic["LocationModuleFlags"].split(",")
-        )
-        self.security_device_id = location_info_basic["SecurityDeviceID"]
-        self._device_list = location_info_basic["DeviceList"]
-        self.parent = parent
-        self.ac_loss = None
-        self.low_battery = None
-        self.cover_tampered = None
-        self.last_updated_timestamp_ticks = None
-        self.configuration_sequence_number = None
-        self.arming_state = None
-        self.zones = {}
-        self.usercode = DEFAULT_USERCODE
-        self._auto_bypass_low_battery = False
-
-    def __str__(self):
-        """Return a text string that is printable."""
-        data = (
-            f"LOCATION {self.location_id} - {self.location_name}\n\n"
-            f"PhotoURL: {self._photo_url}\n"
-            f"SecurityDeviceID: {self.security_device_id}\n"
-            f"DeviceList: {self._device_list}\n"
-            f"AcLoss: {self.ac_loss}\n"
-            f"LowBattery: {self.low_battery}\n"
-            f"IsCoverTampered: {self.cover_tampered}\n"
-            f"Arming State: {self.arming_state}\n"
-            f"LocationModuleFlags:\n"
-        )
-
-        for key, value in self._module_flags.items():
-            data = data + f"  {key}: {value}\n"
-
-        data = data + "\n"
-
-        zones = f"ZONES: {len(self.zones)}\n\n"
-        for zone in self.zones:
-            zones = zones + str(self.zones[zone])
-
-        return data + zones
-
-    @property
-    def auto_bypass_low_battery(self):
-        """Return true if set to automatically bypass a low battery."""
-        return self._auto_bypass_low_battery
-
-    @auto_bypass_low_battery.setter
-    def auto_bypass_low_battery(self, value: bool):
-        """Set to automatically bypass a low battery."""
-        self._auto_bypass_low_battery = value
-
-    def set_zone_details(self, zone_status):
-        """Update from GetZonesListInStateEx_V1. Return true if successful."""
-        zones = zone_status.get("Zones")
-        if zones is None:
-            return False
-
-        zone_info = zones.get("ZoneStatusInfoWithPartitionId")
-        if zone_info is None:
-            return False
-
-        # probabaly shouldn't clear zones
-        # self.locations[location_id].zones.clear()
-
-        for zone in zone_info:
-            self.zones[zone["ZoneID"]] = TotalConnectZone(zone)
-
-        return True
-
-    def set_status(self, data):
-        """Update from 'PanelMetadataAndStatus'. Return true on success."""
-        if data is None:
-            return False
-
-        self.ac_loss = data.get("IsInACLoss")
-        self.low_battery = data.get("IsInLowBattery")
-        self.cover_tampered = data.get("IsCoverTampered")
-        self.last_updated_timestamp_ticks = data.get("LastUpdatedTimestampTicks")
-        self.configuration_sequence_number = data.get("ConfigurationSequenceNumber")
-
-        if "Partitions" not in data:
-            return False
-
-        if "PartitionInfo" not in data["Partitions"]:
-            return False
-
-        self.arming_state = data["Partitions"]["PartitionInfo"][0]["ArmingState"]
-
-        if "Zones" not in data:
-            return False
-
-        if data["Zones"] is None:
-            logging.info(
-                f"total-connect-client returned zero zones. "
-                f"Sync your panel using the TotalConnect app or website.)."
-            )
-            return False
-
-        if "ZoneInfo" not in data["Zones"]:
-            return False
-
-        for zone in data["Zones"]["ZoneInfo"]:
-            if zone["ZoneID"] in self.zones:
-                self.zones[zone["ZoneID"]].update(zone)
-            else:
-                self.zones[zone["ZoneID"]] = TotalConnectZone(zone)
-
-            if (
-                self.zones[zone["ZoneID"]].is_low_battery()
-                and self._auto_bypass_low_battery
-            ):
-                self.parent.zone_bypass(zone["ZoneID"], self.location_id)
-
-        return True
-
-    def is_low_battery(self):
-        """Return true if low battery."""
-        return self.low_battery is True
-
-    def is_ac_loss(self):
-        """Return true if AC loss."""
-        return self.ac_loss is True
-
-    def is_cover_tampered(self):
-        """Return true if cover is tampered."""
-        return self.cover_tampered is True
-
-    def is_arming(self):
-        """Return true if the system is in the process of arming."""
-        return self.arming_state == self.ARMING
-
-    def is_disarming(self):
-        """Return true if the system is in the process of disarming."""
-        return self.arming_state == self.DISARMING
-
-    def is_pending(self):
-        """Return true if the system is pending an action."""
-        return self.is_disarming() or self.is_arming()
-
-    def is_disarmed(self):
-        """Return True if the system is disarmed."""
-        return self.arming_state in (self.DISARMED, self.DISARMED_BYPASS)
-
-    def is_armed_away(self):
-        """Return True if the system is armed away in any way."""
-        return self.arming_state in (
-            self.ARMED_AWAY,
-            self.ARMED_AWAY_BYPASS,
-            self.ARMED_AWAY_INSTANT,
-            self.ARMED_AWAY_INSTANT_BYPASS,
-        )
-
-    def is_armed_custom_bypass(self):
-        """Return True if the system is armed custom bypass in any way."""
-        return self.arming_state == self.ARMED_CUSTOM_BYPASS
-
-    def is_armed_home(self):
-        """Return True if the system is armed home/stay in any way."""
-        return self.arming_state in (
-            self.ARMED_STAY,
-            self.ARMED_STAY_BYPASS,
-            self.ARMED_STAY_INSTANT,
-            self.ARMED_STAY_INSTANT_BYPASS,
-            self.ARMED_STAY_NIGHT,
-        )
-
-    def is_armed_night(self):
-        """Return True if the system is armed night in any way."""
-        return self.arming_state == self.ARMED_STAY_NIGHT
-
-    def is_armed(self):
-        """Return True if the system is armed in any way."""
-        return (
-            self.is_armed_away()
-            or self.is_armed_custom_bypass()
-            or self.is_armed_home()
-            or self.is_armed_night()
-        )
-
-    def is_triggered_police(self):
-        """Return True if the system is triggered for police or medical."""
-        return self.arming_state == self.ALARMING
-
-    def is_triggered_fire(self):
-        """Return True if the system is triggered for fire or smoke."""
-        return self.arming_state == self.ALARMING_FIRE_SMOKE
-
-    def is_triggered_gas(self):
-        """Return True if the system is triggered for carbon monoxide."""
-        return self.arming_state == self.ALARMING_CARBON_MONOXIDE
-
-    def is_triggered(self):
-        """Return True if the system is triggered in any way."""
-        return (
-            self.is_triggered_fire()
-            or self.is_triggered_gas()
-            or self.is_triggered_police()
-        )
-
-    def set_usercode(self, usercode):
-        """Set the usercode. Return true if successful."""
-        if self.parent.validate_usercode(self.security_device_id, usercode):
-            self.usercode = usercode
-            return True
-
-        return False
-
-
-class TotalConnectZone:
-    """TotalConnectZone class."""
-
-    def __init__(self, zone):
-        """Initialize."""
-        self.id = zone.get("ZoneID")
-        self.description = zone.get("ZoneDescription")
-        self.status = zone.get("ZoneStatus")
-        self.partition = zone.get("PartitionID")
-        self.zone_type_id = zone.get("ZoneTypeId")
-        self.can_be_bypassed = zone.get("CanBeBypassed")
-
-    def update(self, zone):
-        """Update the zone."""
-        if self.id == zone.get("ZoneID"):
-            self.description = zone.get("ZoneDescription")
-            self.partition = zone.get("PartitionID")
-            self.status = zone.get("ZoneStatus")
-        else:
-            raise Exception("ZoneID does not match in TotalConnectZone.")
-
-    def __str__(self):
-        """Return a string that is printable."""
-        return (
-            f"Zone {self.id} - {self.description}\n"
-            f"Partition: {self.partition}\t"
-            f"Type: {self.zone_type_id}\t"
-            f"CanBeBypassed: {self.can_be_bypassed}\t"
-            f"Status: {self.status}\n\n"
-        )
-
-    def is_bypassed(self):
-        """Return true if the zone is bypassed."""
-        return self.status & ZONE_STATUS_BYPASSED > 0
-
-    def bypass(self):
-        """Set is_bypassed status."""
-        self.status = ZONE_STATUS_BYPASSED
-
-    def is_faulted(self):
-        """Return true if the zone is faulted."""
-        return self.status & ZONE_STATUS_FAULT > 0
-
-    def is_tampered(self):
-        """Return true if zone is tampered."""
-        return self.status & ZONE_STATUS_TROUBLE > 0
-
-    def is_low_battery(self):
-        """Return true if low battery."""
-        return self.status & ZONE_STATUS_LOW_BATTERY > 0
-
-    def is_troubled(self):
-        """Return true if zone is troubled."""
-        return self.status & ZONE_STATUS_TROUBLE > 0
-
-    def is_triggered(self):
-        """Return true if zone is triggered."""
-        return self.status & ZONE_STATUS_TRIGGERED > 0
-
-    def is_type_button(self):
-        """Return true if zone is a button."""
-        return self.zone_type_id == ZONE_TYPE_SECURITY and self.can_be_bypassed == 0
-
-    def is_type_security(self):
-        """Return true if zone type is security."""
-        return self.zone_type_id in (
-            ZONE_TYPE_SECURITY,
-            ZONE_TYPE_LYRIC_CONTACT,
-            ZONE_TYPE_LYRIC_MOTION,
-            ZONE_TYPE_LYRIC_POLICE,
-            ZONE_TYPE_LYRIC_LOCAL_ALARM,
-        )
-
-    def is_type_motion(self):
-        """Return true if zone type is motion."""
-        return self.zone_type_id == ZONE_TYPE_LYRIC_MOTION
-
-    def is_type_fire(self):
-        """Return true if zone type is fire or smoke."""
-        return self.zone_type_id in (ZONE_TYPE_FIRE_SMOKE, ZONE_TYPE_LYRIC_TEMP)
-
-    def is_type_carbon_monoxide(self):
-        """Return true if zone type is carbon monoxide."""
-        return self.zone_type_id == ZONE_TYPE_CARBON_MONOXIDE
-
-
-class TotalConnectUser:
-    """User for Total Connect."""
-
-    def __init__(self, user_info):
-        """Initialize based on UserInfo from LoginAndGetSessionDetails."""
-        self._user_id = user_info["UserID"]
-        self._username = user_info["Username"]
-        self._features = dict(
-            x.split("=") for x in user_info["UserFeatureList"].split(",")
-        )
-        self._master_user = self._features["Master"] == "1"
-        self._user_admin = self._features["User Administration"] == "1"
-        self._config_admin = self._features["Configuration Administration"] == "1"
-
-        if self.security_problem():
-            logging.warning(
-                f"Total Connect user {self._username} has one or "
-                "more permissions that are not necessary. Remove "
-                "permissions from this user or create a new user "
-                "with minimal permissions."
-            )
-
-    def security_problem(self):
-        """Run security checks. Return true if problem."""
-        problem = False
-
-        if self._master_user:
-            logging.warning(f"User {self._username} is a master user.")
-            problem = True
-
-        if self._user_admin:
-            logging.warning(f"User {self._username} is a user administrator.")
-            problem = True
-
-        if self._config_admin:
-            logging.warning(
-                f"User {self._username} " "is a configuration administrator."
-            )
-            problem = True
-
-        return problem
-
-    def __str__(self):
-        """Return a string that is printable."""
-        data = (
-            f"Username: {self._username}\n"
-            f"UserID: {self._user_id}\n"
-            f"Master User: {self._master_user}\n"
-            f"User Administrator: {self._user_admin}\n"
-            f"Configuration Administrator: {self._config_admin}\n"
-            "User features:\n"
-        )
-
-        for key, value in self._features.items():
-            data = data + f"  {key}: {value}\n"
-
-        return data + "\n"
+"""Total Connect Client."""
+
+import logging
+import time
+import warnings
+
+import zeep
+
+from location import TotalConnectLocation
+from user import TotalConnectUser
+
+PROJECT_URL = "https://github.com/craigjmidwinter/total-connect-client"
+
+ARM_TYPE_AWAY = 0
+ARM_TYPE_STAY = 1
+ARM_TYPE_STAY_INSTANT = 2
+ARM_TYPE_AWAY_INSTANT = 3
+ARM_TYPE_STAY_NIGHT = 4
+
+RESULT_SUCCESS = 0
+
+GET_ALL_SENSORS_MASK_STATUS_SUCCESS = 0
+
+DEFAULT_USERCODE = "-1"
+
+LOGGER = logging.getLogger(__name__)
+
+
+class AuthenticationError(Exception):
+    """Authentication Error class."""
+
+    def __init__(self, *args, **kwargs):
+        """Initialize."""
+        Exception.__init__(self, *args, **kwargs)
+
+
+class TotalConnectClient:
+    """Client for Total Connect."""
+
+    INVALID_SESSION = -102
+    SUCCESS = 0
+    ARM_SUCCESS = 4500
+    DISARM_SUCCESS = 4500
+    SESSION_INITIATED = 4500
+    CONNECTION_ERROR = 4101
+    FAILED_TO_CONNECT = -4104
+    USER_CODE_INVALID = -4106
+    USER_CODE_UNAVAILABLE = -4114
+    COMMAND_FAILED = -4502
+    BAD_USER_OR_PASSWORD = -50004
+    AUTHENTICATION_FAILED = -100
+    FEATURE_NOT_SUPPORTED = -120
+
+    MAX_REQUEST_ATTEMPTS = 10
+
+    def __init__(
+        self,
+        username,
+        password,
+        usercodes={"default": DEFAULT_USERCODE},
+        auto_bypass_battery=False,
+    ):
+        """Initialize."""
+        self.times = {}
+        self.time_start = time.time()
+        self.soapClient = None
+        self.soap_base = "self.soapClient.service."
+        self.soap_ready = False
+
+        self.application_id = "14588"
+        self.application_version = "1.0.34"
+        self.username = username
+        self.password = password
+
+        if not isinstance(usercodes, dict):
+            self.usercodes = {"default": DEFAULT_USERCODE}
+        else:
+            self.usercodes = usercodes
+
+        self.auto_bypass_low_battery = auto_bypass_battery
+        self.token = False
+        self._valid_credentials = (
+            None  # None at start, True after login, False if login fails
+        )
+        self._populated = False
+        self._module_flags = None
+        self._user = None
+        self.locations = {}
+        self.authenticate()
+        self.times["__init__"] = time.time() - self.time_start
+
+    def __str__(self):
+        """Return a text string that is printable."""
+        data = (
+            f"CLIENT\n\n"
+            f"Username: {self.username}\n"
+            f"Password: {self.password}\n"
+            f"Usercode: {self.usercodes}\n"
+            f"Auto Bypass Low Battery: {self.auto_bypass_low_battery}\n"
+            f"Valid Credentials: {self._valid_credentials}\n"
+            f"Module Flags:\n"
+        )
+
+        for key, value in self._module_flags.items():
+            data = data + f"  {key}: {value}\n"
+
+        data = data + str(self._user)
+
+        locations = f"LOCATIONS: {len(self.locations)}\n\n"
+        for location in self.locations:
+            locations = locations + str(self.locations[location])
+
+        return data + locations
+
+    def get_times(self):
+        """Return a string with times."""
+        self.times["total running time"] = time.time() - self.time_start
+        msg = "total-connect-client time info (seconds):\n"
+        for key, value in self.times.items():
+            msg = msg + f"  {key}: {value}\n"
+
+        return msg
+
+    def setup_soap(self):
+        """Set up soap for use."""
+        self.soapClient = zeep.Client("https://rs.alarmnet.com/TC21api/tc2.asmx?WSDL")
+        self.soap_ready = True
+
+    def request(self, request, attempts=0):
+        """Send a SOAP request."""
+        if self.soap_ready is False:
+            self.setup_soap()
+
+        if attempts < self.MAX_REQUEST_ATTEMPTS:
+            attempts += 1
+            response = eval(self.soap_base + request)
+
+            if response.ResultCode in (
+                self.SUCCESS,
+                self.FEATURE_NOT_SUPPORTED,
+                self.ARM_SUCCESS,
+                self.DISARM_SUCCESS,
+                self.SESSION_INITIATED,
+                self.USER_CODE_INVALID,
+            ):
+                return zeep.helpers.serialize_object(response)
+            if response.ResultCode == self.INVALID_SESSION:
+                LOGGER.debug(
+                    f"invalid session (attempt number {attempts})."
+                )
+                self.token = False
+                self.authenticate()
+                return self.request(request, attempts)
+            if response.ResultCode == self.CONNECTION_ERROR:
+                LOGGER.debug(
+                    f"connection error (attempt number {attempts})."
+                )
+                time.sleep(3)
+                return self.request(request, attempts)
+            if response.ResultCode == self.FAILED_TO_CONNECT:
+                LOGGER.debug(
+                    f"failed to connect with security system "
+                    f"(attempt number {attempts})."
+                )
+                time.sleep(3)
+                return self.request(request, attempts)
+            if response.ResultCode in (
+                self.BAD_USER_OR_PASSWORD,
+                self.AUTHENTICATION_FAILED,
+                self.USER_CODE_UNAVAILABLE,
+            ):
+                LOGGER.debug("authentication failed.")
+                return zeep.helpers.serialize_object(response)
+
+            LOGGER.warning(
+                f"unknown result code "
+                f"{response.ResultCode} with message: {response.ResultData}."
+            )
+            return zeep.helpers.serialize_object(response)
+
+        raise Exception(
+            "total-connect-client could not execute request.  Maximum attempts tried."
+        )
+
+    def authenticate(self):
+        """Login to the system and populate details.  Return true if successful."""
+        start_time = time.time()
+        if self._valid_credentials is not False:
+
+            if self._populated:
+                response = self.request(
+                    "AuthenticateUserLogin(self.username, self.password, "
+                    "self.application_id, self.application_version)"
+                )
+            else:
+                # this request is very slow, so only use it when necessary
+                response = self.request(
+                    "LoginAndGetSessionDetails(self.username, self.password, "
+                    "self.application_id, self.application_version)"
+                )
+
+            if response["ResultCode"] == self.SUCCESS:
+                LOGGER.debug("Login Successful")
+                self.token = response["SessionID"]
+                self._valid_credentials = True
+                if not self._populated:
+                    self.populate_details(response)
+                    self._populated = True
+                self.times["authenticate()"] = time.time() - start_time
+                return True
+
+            self._valid_credentials = False
+            self.token = False
+            LOGGER.error(
+                f"Unable to authenticate with Total Connect. ResultCode: "
+                f"{response['ResultCode']}. ResultData: {response['ResultData']}"
+            )
+
+        LOGGER.debug(
+            "total-connect-client attempting login with known bad credentials."
+        )
+        self.times["authenticate()"] = time.time() - start_time
+        return False
+
+    def validate_usercode(self, device_id, usercode):
+        """Return true if the usercode is valid for the device."""
+        response = self.request(
+            f"ValidateUserCode(self.token, " f"{device_id}, '{usercode}')"
+        )
+
+        if response["ResultCode"] == self.SUCCESS:
+            return True
+
+        if response["ResultCode"] in (
+            self.USER_CODE_INVALID,
+            self.USER_CODE_UNAVAILABLE,
+        ):
+            LOGGER.warning(
+                f"usercode '{usercode}' " f"invalid for device {device_id}."
+            )
+            return False
+
+        LOGGER.error(
+            f"Unknown response for validate_usercode. "
+            f"ResultCode: {response['ResultCode']}. "
+            f"ResultData: {response['ResultData']}"
+        )
+
+        return False
+
+    def is_logged_in(self):
+        """Return true if the client is logged into Total Connect service."""
+        return self.token is not False
+
+    def log_out(self):
+        """Return true on logout of Total Connect service, or if not logged in."""
+        if self.is_logged_in():
+            response = self.request("Logout(self.token)")
+
+            if response["ResultCode"] == self.SUCCESS:
+                LOGGER.info("Logout Successful")
+                self.token = False
+                return True
+
+        return False
+
+    def is_valid_credentials(self):
+        """Return true if the credentials are known to be valid."""
+        return self._valid_credentials is True
+
+    def populate_details(self, response):
+        """Populate system details."""
+        start_time = time.time()
+        location_data = response["Locations"]["LocationInfoBasic"]
+
+        self._module_flags = dict(
+            x.split("=") for x in response["ModuleFlags"].split(",")
+        )
+
+        self._user = TotalConnectUser(response["UserInfo"])
+
+        for location in location_data:
+            location_id = location["LocationID"]
+
+            # self.locations[location_id] = TotalConnectLocation(location, self)
+            new_location = TotalConnectLocation(location, self)
+
+            # set auto_bypass
+            new_location.auto_bypass_low_battery = self.auto_bypass_low_battery
+
+            # set the usercode for the location
+            if location_id in self.usercodes:
+                new_location.usercode = self.usercodes[location_id]
+            elif str(location_id) in self.usercodes:
+                new_location.usercode = self.usercodes[str(location_id)]
+            else:
+                LOGGER.warning(f"No usercode for location {location_id}.")
+
+            new_location.get_partition_details()
+            new_location.get_zone_details()
+            new_location.get_panel_meta_data()
+            self.locations[location_id] = new_location
+
+        if len(self.locations) < 1:
+            Exception("No locations found!")
+
+        self.times["populate_details()"] = time.time() - start_time
+
+    def keep_alive(self):
+        """Keep the token alive to avoid server timeouts."""
+        LOGGER.info("total-connect-client initiating Keep Alive")
+
+        response = self.soapClient.service.KeepAlive(self.token)
+
+        if response.ResultCode != self.SUCCESS:
+            self.authenticate()
+
+        return response.ResultCode
+
+    def arm_away(self, location_id):
+        """Arm the system (Away)."""
+        warnings.warn(
+            "Using deprecated client.arm_away(). " "Use location.arm_away().",
+            DeprecationWarning,
+        )
+        return self.arm(ARM_TYPE_AWAY, location_id)
+
+    def arm_stay(self, location_id):
+        """Arm the system (Stay)."""
+        warnings.warn(
+            "Using deprecated client.arm_stay(). " "Use location.arm_stay().",
+            DeprecationWarning,
+        )
+        return self.arm(ARM_TYPE_STAY, location_id)
+
+    def arm_stay_instant(self, location_id):
+        """Arm the system (Stay - Instant)."""
+        warnings.warn(
+            "Using deprecated client.arm_stay_instant(). "
+            "Use location.arm_stay_instant().",
+            DeprecationWarning,
+        )
+        return self.arm(ARM_TYPE_STAY_INSTANT, location_id)
+
+    def arm_away_instant(self, location_id):
+        """Arm the system (Away - Instant)."""
+        warnings.warn(
+            "Using deprecated client.arm_away_instant(). "
+            "Use location.arm_away_instant().",
+            DeprecationWarning,
+        )
+        return self.arm(ARM_TYPE_AWAY_INSTANT, location_id)
+
+    def arm_stay_night(self, location_id):
+        """Arm the system (Stay - Night)."""
+        warnings.warn(
+            "Using deprecated client.arm_stay_night(). "
+            "Use location.arm_stay_night().",
+            DeprecationWarning,
+        )
+        return self.arm(ARM_TYPE_STAY_NIGHT, location_id)
+
+    def arm(self, arm_type, location_id):
+        """Arm the system. Return True if successful."""
+        warnings.warn(
+            "Using deprecated client.arm(). " "Use location.arm().", DeprecationWarning
+        )
+        return self.locations[location_id].arm(arm_type)
+
+    def arm_custom(self, arm_type, location_id):
+        """Arm custom the system.  Return true if successul."""
+        ZONE_INFO = {"ZoneID": "12", "ByPass": False, "ZoneStatus": 0}
+
+        ZONES_LIST = {}
+        ZONES_LIST[0] = ZONE_INFO
+
+        CUSTOM_ARM_SETTINGS = {"ArmMode": "1", "ArmDelay": "5", "ZonesList": ZONES_LIST}
+
+        result = self.request(
+            f"CustomArmSecuritySystem(self.token, "
+            f"{location_id}, "
+            f"{self.locations[location_id].security_device_id}, "
+            f"{arm_type}, '{self.locations[location_id].usercode}', "
+            f"{CUSTOM_ARM_SETTINGS})"
+        )
+
+        if result["ResultCode"] != self.SUCCESS:
+            LOGGER.error(
+                f"Could not arm custom. ResultCode: {result['ResultCode']}. "
+                f"ResultData: {result['ResultData']}"
+            )
+            return False
+
+        # remove after this is all working
+        print(
+            f"arm_custom ResultCode: {result['ResultCode']}. "
+            f"arm_custom ResultData: {result['ResultData']}"
+        )
+
+        return True
+
+    def get_custom_arm_settings(self, location_id):
+        """Get custom arm settings.  Return true if successful."""
+        warnings.warn(
+            "Using deprecated client.get_custom_arm_settings(). "
+            "Use location.get_custom_arm_settings().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].get_custom_arm_settings()
+
+    def get_panel_meta_data(self, location_id):
+        """Get all meta data about the alarm panel."""
+        warnings.warn(
+            "Using deprecated client.get_panel_meta_data(). "
+            "Use location.get_panel_meta_data().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].get_panel_meta_data()
+
+    def zone_status(self, location_id, zone_id):
+        """Get status of a zone."""
+        warnings.warn(
+            "Using deprecated client.zone_status(). " "Use location.zone_status().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].zone_status(zone_id)
+
+    def get_armed_status(self, location_id):
+        """Get the status of the panel."""
+        warnings.warn(
+            "Using deprecated client.zone_status(). " "Use location.zone_status().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].get_armed_status()
+
+    def disarm(self, location_id):
+        """Disarm the system. Return True if successful."""
+        warnings.warn(
+            "Using deprecated client.disarm(). " "Use location.disarm().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].disarm()
+
+    def zone_bypass(self, zone_id, location_id):
+        """Bypass a zone.  Return true if successful."""
+        warnings.warn(
+            "Using deprecated client.zone_bypass(). " "Use location.zone_bypass().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].zone_bypass(zone_id)
+
+    def get_zone_details(self, location_id):
+        """Get Zone details. Return True if successful."""
+        warnings.warn(
+            "Using deprecated client.get_zone_details(). "
+            "Use location.get_zone_details().",
+            DeprecationWarning,
+        )
+        return self.locations[location_id].get_zone_details()
diff --git a/total_connect_client/const.py b/total_connect_client/const.py
new file mode 100644
index 0000000..4d50dd0
--- /dev/null
+++ b/total_connect_client/const.py
@@ -0,0 +1,3 @@
+"""Total Connect Client constants."""
+
+RESULT_SUCCESS = 0
diff --git a/total_connect_client/device.py b/total_connect_client/device.py
new file mode 100644
index 0000000..5095bb3
--- /dev/null
+++ b/total_connect_client/device.py
@@ -0,0 +1,36 @@
+"""Total Connect Device."""
+
+
+class TotalConnectDevice:
+    """Device class for Total Connect."""
+
+    def __init__(self, info):
+        """Initialize device based on DeviceInfoBasic."""
+        self.id = info.get("DeviceID")
+        self.name = info.get("DeviceName")
+        self.class_id = info.get("DeviceClassID")
+        self.serial_number = info.get("DeviceSerialNumber")
+        self.security_panel_type_id = info.get("SecurityPanelTypeID")
+        self.serial_text = info.get("DeviceSerialText")
+
+        flags = info.get("DeviceFlags")
+        if flags is None:
+            self.flags = {}
+        else:
+            self.flags = dict(x.split("=") for x in flags.split(","))
+
+    def __str__(self):
+        """Return a string that is printable."""
+        data = (
+            f"DEVICE {self.id} - {self.name}\n"
+            f"  ClassID: {self.class_id}\n"
+            f"  Security Panel Type ID: {self.security_panel_type_id}\n"
+            f"  Serial Number: {self.serial_number}\n"
+            f"  Serial Text: {self.serial_text}\n"
+            f"  Device Flags:\n"
+        )
+
+        for key, value in self.flags.items():
+            data = data + f"    {key}: {value}\n"
+
+        return data
diff --git a/total_connect_client/info.py b/total_connect_client/info.py
index a8cb316..ebeb293 100644
--- a/total_connect_client/info.py
+++ b/total_connect_client/info.py
@@ -1,5 +1,6 @@
 """Test your system from the command line."""
 
+import getpass
 import logging
 import sys
 
@@ -7,13 +8,16 @@
 
 logging.basicConfig(filename="test.log", level=logging.DEBUG)
 
-if len(sys.argv) != 3:
-    print("usage:  python3 test.py username password\n")
-    sys.exit()
+if len(sys.argv) < 2 or len(sys.argv) > 3:
+    print("usage:  python3 test.py username [password]\n")
+    sys.exit(1)
 
 
 USERNAME = sys.argv[1]
-PASSWORD = sys.argv[2]
+if len(sys.argv) == 2:
+    PASSWORD = getpass.getpass()
+else:
+    PASSWORD = sys.argv[2]
 
 TC = TotalConnectClient.TotalConnectClient(USERNAME, PASSWORD)
 
diff --git a/total_connect_client/location.py b/total_connect_client/location.py
new file mode 100644
index 0000000..f5ed161
--- /dev/null
+++ b/total_connect_client/location.py
@@ -0,0 +1,606 @@
+"""Total Connect Location."""
+
+import logging
+
+from device import TotalConnectDevice
+from partition import TotalConnectPartition
+from zone import TotalConnectZone
+
+ARM_TYPE_AWAY = 0
+ARM_TYPE_STAY = 1
+ARM_TYPE_STAY_INSTANT = 2
+ARM_TYPE_AWAY_INSTANT = 3
+ARM_TYPE_STAY_NIGHT = 4
+
+RESULT_SUCCESS = 0
+
+DEFAULT_USERCODE = "-1"
+
+LOGGER = logging.getLogger(__name__)
+
+class TotalConnectLocation:
+    """TotalConnectLocation class."""
+
+    # Location relevant ResultCode
+    SUCCESS = 0
+    ARM_SUCCESS = 4500
+    DISARM_SUCCESS = 4500
+    COMMAND_FAILED = -4502
+    USER_CODE_INVALID = -4106
+    USER_CODE_UNAVAILABLE = -4114
+    ZONE_BYPASS_SUCCESS = 0
+
+    # ArmingState
+    DISARMED = 10200
+    DISARMED_BYPASS = 10211
+    ARMED_AWAY = 10201
+    ARMED_AWAY_BYPASS = 10202
+    ARMED_AWAY_INSTANT = 10205
+    ARMED_AWAY_INSTANT_BYPASS = 10206
+    ARMED_CUSTOM_BYPASS = 10223
+    ARMED_STAY = 10203
+    ARMED_STAY_BYPASS = 10204
+    ARMED_STAY_INSTANT = 10209
+    ARMED_STAY_INSTANT_BYPASS = 10210
+    ARMED_STAY_NIGHT = 10218
+    ARMING = 10307
+    DISARMING = 10308
+    ALARMING = 10207
+    ALARMING_FIRE_SMOKE = 10212
+    ALARMING_CARBON_MONOXIDE = 10213
+
+    KNOWN_PANEL_STATES = [
+        DISARMED,
+        DISARMED_BYPASS,
+        ARMED_AWAY,
+        ARMED_AWAY_BYPASS,
+        ARMED_AWAY_INSTANT,
+        ARMED_AWAY_INSTANT_BYPASS,
+        ARMED_CUSTOM_BYPASS,
+        ARMED_STAY,
+        ARMED_STAY_BYPASS,
+        ARMED_STAY_INSTANT,
+        ARMED_STAY_INSTANT_BYPASS,
+        ARMED_STAY_NIGHT,
+        ARMING,
+        DISARMING,
+        ALARMING,
+        ALARMING_FIRE_SMOKE,
+        ALARMING_CARBON_MONOXIDE,
+    ]
+
+    def __init__(self, location_info_basic, parent):
+        """Initialize based on a 'LocationInfoBasic'."""
+        self.location_id = location_info_basic["LocationID"]
+        self.location_name = location_info_basic["LocationName"]
+        self._photo_url = location_info_basic["PhotoURL"]
+        self._module_flags = dict(
+            x.split("=") for x in location_info_basic["LocationModuleFlags"].split(",")
+        )
+        self.security_device_id = location_info_basic["SecurityDeviceID"]
+        self.parent = parent
+        self.ac_loss = None
+        self.low_battery = None
+        self.cover_tampered = None
+        self.last_updated_timestamp_ticks = None
+        self.configuration_sequence_number = None
+        self.arming_state = None
+        self.partitions = {}
+        self._partition_list = None
+        self.zones = {}
+        self.usercode = DEFAULT_USERCODE
+        self._auto_bypass_low_battery = False
+
+        self.devices = {}
+        if "DeviceList" in location_info_basic:
+            if location_info_basic["DeviceList"] is not None:
+                if "DeviceInfoBasic" in location_info_basic["DeviceList"]:
+                    device_info_basic = location_info_basic["DeviceList"][
+                        "DeviceInfoBasic"
+                    ]
+                    if device_info_basic is not None:
+                        for single_device in device_info_basic:
+                            device = TotalConnectDevice(single_device)
+                            self.devices[device.id] = device
+
+    def __str__(self):
+        """Return a text string that is printable."""
+        data = (
+            f"LOCATION {self.location_id} - {self.location_name}\n\n"
+            f"PhotoURL: {self._photo_url}\n"
+            f"SecurityDeviceID: {self.security_device_id}\n"
+            f"AcLoss: {self.ac_loss}\n"
+            f"LowBattery: {self.low_battery}\n"
+            f"IsCoverTampered: {self.cover_tampered}\n"
+            f"Arming State: {self.arming_state}\n"
+            f"LocationModuleFlags:\n"
+        )
+
+        for key, value in self._module_flags.items():
+            data = data + f"  {key}: {value}\n"
+
+        data = data + "\n"
+
+        devices = f"DEVICES: {len(self.devices)}\n\n"
+        for device in self.devices:
+            devices = devices + str(self.devices[device]) + "\n"
+
+        partitions = f"PARTITIONS: {len(self.partitions)}\n\n"
+        for partition in self.partitions:
+            partitions = partitions + str(self.partitions[partition]) + "\n"
+
+        zones = f"ZONES: {len(self.zones)}\n\n"
+        for zone in self.zones:
+            zones = zones + str(self.zones[zone])
+
+        return data + devices + partitions + zones
+
+    @property
+    def auto_bypass_low_battery(self):
+        """Return true if set to automatically bypass a low battery."""
+        return self._auto_bypass_low_battery
+
+    @auto_bypass_low_battery.setter
+    def auto_bypass_low_battery(self, value: bool):
+        """Set to automatically bypass a low battery."""
+        self._auto_bypass_low_battery = value
+
+    def set_zone_details(self, zone_status):
+        """Update from GetZonesListInStateEx_V1. Return true if successful."""
+        if zone_status is None:
+            return False
+
+        if "Zones" not in zone_status:
+            return False
+        if zone_status["Zones"] is None:
+            return False
+
+        zones = zone_status["Zones"]
+
+        zone_info = zones.get("ZoneStatusInfoWithPartitionId")
+        if zone_info is None:
+            return False
+
+        # probabaly shouldn't clear zones
+        # self.locations[location_id].zones.clear()
+
+        for zone in zone_info:
+            self.zones[zone["ZoneID"]] = TotalConnectZone(zone)
+
+        return True
+
+    def get_panel_meta_data(self):
+        """Get all meta data about the alarm panel."""
+        # see https://rs.alarmnet.com/TC21api/tc2.asmx?op=GetPanelMetaDataAndFullStatus
+        result = self.parent.request(
+            f"GetPanelMetaDataAndFullStatusEx_V2(self.token, {self.location_id}, 0, 0, {self._partition_list})"
+        )
+
+        if result["ResultCode"] != RESULT_SUCCESS:
+            LOGGER.error(
+                f"Could not retrieve panel meta data. "
+                f"ResultCode: {result['ResultCode']}. ResultData: {result['ResultData']}"
+            )
+            return False
+
+        if "PanelMetadataAndStatus" not in result:
+            LOGGER.warning("Panel_meta_data is empty.")
+            return False
+
+        if not self.set_status(result["PanelMetadataAndStatus"]):
+            return False
+
+        if "ArmingState" not in result:
+            LOGGER.warning(
+                "GetPanelMetaDataAndFullStatus result does not contain ArmingState."
+            )
+            return False
+
+        return self.set_arming_state(result["ArmingState"])
+
+    def set_arming_state(self, new_state):
+        """Set arming state.  True on success."""
+        if new_state is None:
+            return False
+
+        self.arming_state = new_state
+        return True
+
+    def set_status(self, data):
+        """Update from 'PanelMetadataAndStatus'. Return true on success."""
+        if data is None:
+            return False
+
+        self.ac_loss = data.get("IsInACLoss")
+        self.low_battery = data.get("IsInLowBattery")
+        self.cover_tampered = data.get("IsCoverTampered")
+        self.last_updated_timestamp_ticks = data.get("LastUpdatedTimestampTicks")
+        self.configuration_sequence_number = data.get("ConfigurationSequenceNumber")
+
+        if "Partitions" not in data:
+            return False
+
+        if not self.update_partitions(data["Partitions"]):
+            return False
+
+        if "Zones" not in data:
+            LOGGER.warning("Zones not found in PanelMetaDataAndStatus in set_status()")
+            return False
+
+        if not self.update_zones(data["Zones"]):
+            return False
+
+        return True
+
+    def get_zone_details(self):
+        """Get Zone details. Return True if successful."""
+        result = self.parent.request(
+            f"GetZonesListInStateEx_V1(self.token, {self.location_id}, {self._partition_list}, 0)"
+        )
+
+        if result["ResultCode"] == self.parent.FEATURE_NOT_SUPPORTED:
+            LOGGER.warning(
+                "Getting Zone Details is a feature not supported by "
+                "your Total Connect account or hardware."
+            )
+            return False
+
+        if result["ResultCode"] != RESULT_SUCCESS:
+            LOGGER.error(
+                f"Could not get zone details. "
+                f"ResultCode: {result['ResultCode']}. ResultData: {result['ResultData']}."
+            )
+            return False
+
+        if "ZoneStatus" in result:
+            return self.set_zone_details(result["ZoneStatus"])
+
+        LOGGER.error(
+            f"Could not get zone details. "
+            f"ResultCode: {result['ResultCode']}. ResultData: {result['ResultData']}."
+        )
+        return False
+
+    def update_partitions(self, data):
+        """Update partition info from Partitions."""
+        if "PartitionInfo" not in data:
+            return False
+
+        partition_info = data["PartitionInfo"]
+
+        if partition_info is None:
+            return False
+
+        # TODO:  next line is WRONG, need to update partion.arming_state, NOT location.arming_state
+        self.arming_state = partition_info[0]["ArmingState"]
+
+        # TODO:  loop through partitions and update
+
+        return True
+
+    def update_zones(self, data):
+        """Update zone info from ZoneInfo or ZoneInfoEx."""
+
+        if not data:
+            LOGGER.info(
+                f"no zones found -- sync your panel using TotalConnect app or website"
+            )
+            return False
+
+        zone_info = None
+
+        if "ZoneInfoEx" in data:
+            zone_info = data["ZoneInfoEx"]
+        elif "ZoneInfo" in data:
+            zone_info = data["ZoneInfo"]
+
+        if zone_info is None:
+            return False
+
+        for zone in zone_info:
+            if zone["ZoneID"] in self.zones:
+                self.zones[zone["ZoneID"]].update(zone)
+            else:
+                self.zones[zone["ZoneID"]] = TotalConnectZone(zone)
+
+            if (
+                self.zones[zone["ZoneID"]].is_low_battery()
+                and self._auto_bypass_low_battery
+            ):
+                self.parent.zone_bypass(zone["ZoneID"], self.location_id)
+
+        return True
+
+    def get_partition_details(self):
+        """Get partition details for this location."""
+        # see https://rs.alarmnet.com/TC21api/tc2.asmx?op=GetPartitionsDetails
+
+        result = self.parent.request(
+            f"GetPartitionsDetails(self.token, {self.location_id}, {self.security_device_id})"
+        )
+
+        if result["ResultCode"] != RESULT_SUCCESS:
+            LOGGER.error(
+                f"Could not get partition details for "
+                f"device {self.security_device_id} at "
+                f"location {self.location_id}."
+                f"ResultCode: {result['ResultCode']}. "
+                f"ResultData: {result['ResultData']}"
+            )
+            return False
+
+        if "PartitionsInfoList" not in result:
+            return False
+
+        partition_info_list = result["PartitionsInfoList"]
+
+        if "PartitionDetails" not in partition_info_list:
+            return False
+
+        partition_details = partition_info_list["PartitionDetails"]
+
+        # loop through list and add partitions
+        new_partition_list = []
+        for partition in partition_details:
+            new_partition = TotalConnectPartition(partition, self)
+            self.partitions[new_partition.id] = new_partition
+            new_partition_list.append(new_partition.id)
+
+        self._partition_list = {"int": new_partition_list}
+
+        return True
+
+    def is_low_battery(self):
+        """Return true if low battery."""
+        return self.low_battery is True
+
+    def is_ac_loss(self):
+        """Return true if AC loss."""
+        return self.ac_loss is True
+
+    def is_cover_tampered(self):
+        """Return true if cover is tampered."""
+        return self.cover_tampered is True
+
+    def is_arming(self):
+        """Return true if the system is in the process of arming."""
+        return self.arming_state == self.ARMING
+
+    def is_disarming(self):
+        """Return true if the system is in the process of disarming."""
+        return self.arming_state == self.DISARMING
+
+    def is_pending(self):
+        """Return true if the system is pending an action."""
+        return self.is_disarming() or self.is_arming()
+
+    def is_disarmed(self):
+        """Return True if the system is disarmed."""
+        return self.arming_state in (self.DISARMED, self.DISARMED_BYPASS)
+
+    def is_armed_away(self):
+        """Return True if the system is armed away in any way."""
+        return self.arming_state in (
+            self.ARMED_AWAY,
+            self.ARMED_AWAY_BYPASS,
+            self.ARMED_AWAY_INSTANT,
+            self.ARMED_AWAY_INSTANT_BYPASS,
+        )
+
+    def is_armed_custom_bypass(self):
+        """Return True if the system is armed custom bypass in any way."""
+        return self.arming_state == self.ARMED_CUSTOM_BYPASS
+
+    def is_armed_home(self):
+        """Return True if the system is armed home/stay in any way."""
+        return self.arming_state in (
+            self.ARMED_STAY,
+            self.ARMED_STAY_BYPASS,
+            self.ARMED_STAY_INSTANT,
+            self.ARMED_STAY_INSTANT_BYPASS,
+            self.ARMED_STAY_NIGHT,
+        )
+
+    def is_armed_night(self):
+        """Return True if the system is armed night in any way."""
+        return self.arming_state == self.ARMED_STAY_NIGHT
+
+    def is_armed(self):
+        """Return True if the system is armed in any way."""
+        return (
+            self.is_armed_away()
+            or self.is_armed_custom_bypass()
+            or self.is_armed_home()
+            or self.is_armed_night()
+        )
+
+    def is_triggered_police(self):
+        """Return True if the system is triggered for police or medical."""
+        return self.arming_state == self.ALARMING
+
+    def is_triggered_fire(self):
+        """Return True if the system is triggered for fire or smoke."""
+        return self.arming_state == self.ALARMING_FIRE_SMOKE
+
+    def is_triggered_gas(self):
+        """Return True if the system is triggered for carbon monoxide."""
+        return self.arming_state == self.ALARMING_CARBON_MONOXIDE
+
+    def is_triggered(self):
+        """Return True if the system is triggered in any way."""
+        return (
+            self.is_triggered_fire()
+            or self.is_triggered_gas()
+            or self.is_triggered_police()
+        )
+
+    def set_usercode(self, usercode):
+        """Set the usercode. Return true if successful."""
+        if self.parent.validate_usercode(self.security_device_id, usercode):
+            self.usercode = usercode
+            return True
+
+        return False
+
+    def arm_away(self, partition_id=None):
+        """Arm the system (Away)."""
+        return self.arm(ARM_TYPE_AWAY, partition_id)
+
+    def arm_stay(self, partition_id=None):
+        """Arm the system (Stay)."""
+        return self.arm(ARM_TYPE_STAY, partition_id)
+
+    def arm_stay_instant(self, partition_id=None):
+        """Arm the system (Stay - Instant)."""
+        return self.arm(ARM_TYPE_STAY_INSTANT, partition_id)
+
+    def arm_away_instant(self, partition_id=None):
+        """Arm the system (Away - Instant)."""
+        return self.arm(ARM_TYPE_AWAY_INSTANT, partition_id)
+
+    def arm_stay_night(self, partition_id=None):
+        """Arm the system (Stay - Night)."""
+        return self.arm(ARM_TYPE_STAY_NIGHT, partition_id)
+
+    def arm(self, arm_type, partition_id=None):
+        """Arm the given partition. Return True if successful."""
+        # if no partition is given, arm all partitions
+        # see https://rs.alarmnet.com/TC21api/tc2.asmx?op=ArmSecuritySystemPartitionsV1
+        partition_list = []
+        if partition_id is None:
+
+            partition_list = self._partition_list
+        else:
+            if partition_id not in self.partitions:
+                LOGGER.error(
+                    f"Parition {partition_id} does not exist "
+                    f"for location {self.location_id}."
+                )
+                return False
+
+            partition_list.append(partition_id)
+
+        result = self.parent.request(
+            f"ArmSecuritySystemPartitionsV1(self.token, "
+            f"{self.location_id}, "
+            f"{self.security_device_id}, "
+            f"{arm_type}, "
+            f"'{self.usercode}', "
+            f"{partition_list})"
+        )
+
+        if result["ResultCode"] in (self.ARM_SUCCESS, self.SUCCESS):
+            return True
+
+        if result["ResultCode"] == self.COMMAND_FAILED:
+            LOGGER.warning("Could not arm system. Check if a zone is faulted.")
+            return False
+
+        if result["ResultCode"] in (self.USER_CODE_INVALID, self.USER_CODE_UNAVAILABLE):
+            LOGGER.warning(
+                f"User code {self.usercode} is invalid for location {self.location_id}."
+            )
+            return False
+
+        LOGGER.error(
+            f"Could not arm system. "
+            f"ResultCode: {result['ResultCode']}. "
+            f"ResultData: {result['ResultData']}"
+        )
+        return False
+
+    def disarm(self, partition_id=None):
+        """Disarm the system. Return True if successful."""
+        # if no partition is given, disarm all partitions
+        # see https://rs.alarmnet.com/TC21api/tc2.asmx?op=ArmSecuritySystemPartitionsV1
+        partition_list = []
+        if partition_id is None:
+
+            partition_list = self._partition_list
+        else:
+            if partition_id not in self.partitions:
+                LOGGER.error(
+                    f"Parition {partition_id} does not exist "
+                    f"for location {self.location_id}."
+                )
+                return False
+
+            partition_list.append(partition_id)
+
+        result = self.parent.request(
+            f"DisarmSecuritySystemPartitionsV1(self.token, "
+            f"{self.location_id}, "
+            f"{self.security_device_id}, "
+            f"'{self.usercode}', "
+            f"{partition_list})"
+        )
+
+        if result["ResultCode"] in (self.DISARM_SUCCESS, self.SUCCESS):
+            LOGGER.info("System Disarmed")
+            return True
+
+        if result["ResultCode"] in (self.USER_CODE_INVALID, self.USER_CODE_UNAVAILABLE):
+            LOGGER.warning(
+                f"User code {self.usercode} is invalid for location {self.location_id}."
+            )
+            return False
+
+        LOGGER.error(
+            f"Could not disarm system. "
+            f"ResultCode: {result['ResultCode']}. "
+            f"ResultData: {result['ResultData']}"
+        )
+        return False
+
+    def zone_bypass(self, zone_id):
+        """Bypass a zone.  Return true if successful."""
+        result = self.parent.request(
+            f"Bypass(self.token, "
+            f"{self.location_id}, "
+            f"{self.security_device_id}, "
+            f"{zone_id}, "
+            f"'{self.usercode}')"
+        )
+
+        if result["ResultCode"] is self.ZONE_BYPASS_SUCCESS:
+            self.zones[zone_id].bypass()
+            return True
+
+        LOGGER.error(
+            f"Could not bypass zone {zone_id} at location {self.location_id}."
+            f"ResultCode: {result['ResultCode']}. "
+            f"ResultData: {result['ResultData']}"
+        )
+        return False
+
+    def zone_status(self, zone_id):
+        """Get status of a zone."""
+        z = self.zones.get(zone_id)
+        if z is None:
+            LOGGER.error(f"Zone {zone_id} does not exist.")
+            return None
+
+        return z.status
+
+    def get_armed_status(self):
+        """Get the status of the panel."""
+        self.get_panel_meta_data()
+        # TODO:  return state for the partition ???
+        return self.arming_state
+
+    def get_custom_arm_settings(self):
+        """Get custom arm settings."""
+        result = self.request(
+            f"GetCustomArmSettings(self.token, "
+            f"{self.location_id}, "
+            f"{self.security_device_id})"
+        )
+
+        if result["ResultCode"] != self.SUCCESS:
+            LOGGER.error(
+                f"Could not arm custom. ResultCode: {result['ResultCode']}. "
+                f"ResultData: {result['ResultData']}"
+            )
+            return False
+
+        return result
diff --git a/total_connect_client/partition.py b/total_connect_client/partition.py
new file mode 100644
index 0000000..832d318
--- /dev/null
+++ b/total_connect_client/partition.py
@@ -0,0 +1,152 @@
+"""Total Connect Partition."""
+
+
+class TotalConnectPartition:
+    """Partition class for Total Connect."""
+
+    # ArmingState
+    DISARMED = 10200
+    DISARMED_BYPASS = 10211
+    ARMED_AWAY = 10201
+    ARMED_AWAY_BYPASS = 10202
+    ARMED_AWAY_INSTANT = 10205
+    ARMED_AWAY_INSTANT_BYPASS = 10206
+    ARMED_CUSTOM_BYPASS = 10223
+    ARMED_STAY = 10203
+    ARMED_STAY_BYPASS = 10204
+    ARMED_STAY_INSTANT = 10209
+    ARMED_STAY_INSTANT_BYPASS = 10210
+    ARMED_STAY_NIGHT = 10218
+    ARMING = 10307
+    DISARMING = 10308
+    ALARMING = 10207
+    ALARMING_FIRE_SMOKE = 10212
+    ALARMING_CARBON_MONOXIDE = 10213
+
+    def __init__(self, details, parent):
+        """Initialize Partition based on PartitionDetails."""
+        self.id = details.get("PartitionID")
+        self.arming_state = details.get("ArmingState")
+        self.name = details.get("PartitionName")
+        self.parent = parent
+
+    def __str__(self):
+        """Return a string that is printable."""
+        data = (
+            f"PARTITION {self.id} - {self.name}\n"
+            f"  ArmingState: {self.arming_state}\n"
+        )
+
+        return data
+
+    def update(self, info):
+        """Update partition based on PartitionInfo. True on success."""
+        if info is None:
+            return False
+
+        if "ArmingState" not in info:
+            return False
+
+        self.arming_state = info["ArmingState"]
+        return True
+
+    def arm_away(self):
+        """Arm the partition (Away). True on success."""
+        return self.parent.arm_away(self.id)
+
+    def arm_stay(self):
+        """Arm the partition (Stay). True on success."""
+        return self.parent.arm_stay(self.id)
+
+    def arm_stay_instant(self):
+        """Arm the partition (Stay - Instant). True on success."""
+        return self.parent.arm_stay_instant(self.id)
+
+    def arm_away_instant(self):
+        """Arm the partition (Away - Instant). True on success."""
+        return self.parent.arm_away_instant(self.id)
+
+    def arm_stay_night(self):
+        """Arm the partition (Stay - Night). True on success."""
+        return self.parent.arm_stay_night(self.id)
+
+    def disarm(self):
+        """Disarm the partition. True on success."""
+        return self.parent.disarm(self.id)
+
+    def get_armed_status(self):
+        """Get the status of the panel."""
+        # TODO:  ask parent to update status first?
+        return self.arming_state
+
+    def is_arming(self):
+        """Return true if the system is in the process of arming."""
+        return self.arming_state == self.ARMING
+
+    def is_disarming(self):
+        """Return true if the system is in the process of disarming."""
+        return self.arming_state == self.DISARMING
+
+    def is_pending(self):
+        """Return true if the system is pending an action."""
+        return self.is_disarming() or self.is_arming()
+
+    def is_disarmed(self):
+        """Return True if the system is disarmed."""
+        return self.arming_state in (self.DISARMED, self.DISARMED_BYPASS)
+
+    def is_armed_away(self):
+        """Return True if the system is armed away in any way."""
+        return self.arming_state in (
+            self.ARMED_AWAY,
+            self.ARMED_AWAY_BYPASS,
+            self.ARMED_AWAY_INSTANT,
+            self.ARMED_AWAY_INSTANT_BYPASS,
+        )
+
+    def is_armed_custom_bypass(self):
+        """Return True if the system is armed custom bypass in any way."""
+        return self.arming_state == self.ARMED_CUSTOM_BYPASS
+
+    def is_armed_home(self):
+        """Return True if the system is armed home/stay in any way."""
+        return self.arming_state in (
+            self.ARMED_STAY,
+            self.ARMED_STAY_BYPASS,
+            self.ARMED_STAY_INSTANT,
+            self.ARMED_STAY_INSTANT_BYPASS,
+            self.ARMED_STAY_NIGHT,
+        )
+
+    def is_armed_night(self):
+        """Return True if the system is armed night in any way."""
+        return self.arming_state == self.ARMED_STAY_NIGHT
+
+    def is_armed(self):
+        """Return True if the system is armed in any way."""
+        return (
+            self.is_armed_away()
+            or self.is_armed_custom_bypass()
+            or self.is_armed_home()
+            or self.is_armed_night()
+        )
+
+    def is_triggered_police(self):
+        """Return True if the system is triggered for police or medical."""
+        return self.arming_state == self.ALARMING
+
+    def is_triggered_fire(self):
+        """Return True if the system is triggered for fire or smoke."""
+        return self.arming_state == self.ALARMING_FIRE_SMOKE
+
+    def is_triggered_gas(self):
+        """Return True if the system is triggered for carbon monoxide."""
+        return self.arming_state == self.ALARMING_CARBON_MONOXIDE
+
+    def is_triggered(self):
+        """Return True if the system is triggered in any way."""
+        return (
+            self.is_triggered_fire()
+            or self.is_triggered_gas()
+            or self.is_triggered_police()
+        )
diff --git a/total_connect_client/user.py b/total_connect_client/user.py
new file mode 100644
index 0000000..9091e63
--- /dev/null
+++ b/total_connect_client/user.py
@@ -0,0 +1,64 @@
+"""Total Connect User."""
+
+import logging
+
+LOGGER = logging.getLogger(__name__)
+
+
+class TotalConnectUser:
+    """User for Total Connect."""
+
+    def __init__(self, user_info):
+        """Initialize based on UserInfo from LoginAndGetSessionDetails."""
+        self._user_id = user_info["UserID"]
+        self._username = user_info["Username"]
+        self._features = dict(
+            x.split("=") for x in user_info["UserFeatureList"].split(",")
+        )
+        self._master_user = self._features["Master"] == "1"
+        self._user_admin = self._features["User Administration"] == "1"
+        self._config_admin = self._features["Configuration Administration"] == "1"
+
+        if self.security_problem():
+            LOGGER.warning(
+                f"Total Connect user {self._username} has one or "
+                "more permissions that are not necessary. Remove "
+                "permissions from this user or create a new user "
+                "with minimal permissions."
+            )
+
+    def security_problem(self):
+        """Run security checks. Return true if problem."""
+        problem = False
+
+        if self._master_user:
+            LOGGER.warning(f"User {self._username} is a master user.")
+            problem = True
+
+        if self._user_admin:
+            LOGGER.warning(f"User {self._username} is a user administrator.")
+            problem = True
+
+        if self._config_admin:
+            LOGGER.warning(
+                f"User {self._username} " "is a configuration administrator."
+            )
+            problem = True
+
+        return problem
+
+    def __str__(self):
+        """Return a string that is printable."""
+        data = (
+            f"Username: {self._username}\n"
+            f"UserID: {self._user_id}\n"
+            f"Master User: {self._master_user}\n"
+            f"User Administrator: {self._user_admin}\n"
+            f"Configuration Administrator: {self._config_admin}\n"
+            "User features:\n"
+        )
+
+        for key, value in self._features.items():
+            data = data + f"  {key}: {value}\n"
+
+        return data + "\n"
diff --git a/total_connect_client/zone.py b/total_connect_client/zone.py
new file mode 100644
index 0000000..e0bfa14
--- /dev/null
+++ b/total_connect_client/zone.py
@@ -0,0 +1,172 @@
+"""Total Connect Zone."""
+
+
+import logging
+
+ZONE_STATUS_NORMAL = 0
+ZONE_STATUS_BYPASSED = 1
+ZONE_STATUS_FAULT = 2
+ZONE_STATUS_TROUBLE = 8  # is also Tampered
+ZONE_STATUS_LOW_BATTERY = 64
+ZONE_STATUS_TRIGGERED = 256
+
+ZONE_TYPE_SECURITY = 0
+ZONE_TYPE_LYRIC_CONTACT = 1
+ZONE_TYPE_PROA7_SECURITY = 3
+ZONE_TYPE_LYRIC_MOTION = 4
+ZONE_TYPE_LYRIC_POLICE = 6
+ZONE_TYPE_PROA7_POLICE = 7
+ZONE_TYPE_FIRE_SMOKE = 9
+ZONE_TYPE_PROA7_INTERIOR_DELAY = 10
+ZONE_TYPE_LYRIC_TEMP = 12
+ZONE_TYPE_PROA7_FLOOD = 12
+ZONE_TYPE_CARBON_MONOXIDE = 14
+ZONE_TYPE_PROA7_MEDICAL = 15
+ZONE_TYPE_LYRIC_LOCAL_ALARM = 89
+
+
+class TotalConnectZone:
+    """TotalConnectZone class."""
+
+    def __init__(self, zone):
+        """Initialize."""
+        self.id = zone.get("ZoneID")
+        self.partition = None
+        self.status = None
+        self.zone_type_id = None
+        self.can_be_bypassed = None
+        self.battery_level = None
+        self.signal_strength = None
+        self.sensor_serial_number = None
+        self.loop_number = None
+        self.response_type = None
+        self.alarm_report_state = None
+        self.supervision_type = None
+        self.chime_state = None
+        self.device_type = None
+        self.update(zone)
+
+    def update(self, zone):
+        """Update the zone.  True on success."""
+        if zone is None:
+            return False
+
+        if self.id != zone.get("ZoneID"):
+            raise Exception(
+                f"ZoneID {zone.get('ZoneID')} does not match "
+                f"expected {self.id} in TotalConnectZone."
+            )
+
+        self.description = zone.get("ZoneDescription")
+        self.partition = zone.get("PartitionID")
+        self.status = zone.get("ZoneStatus")
+        self.can_be_bypassed = zone.get("CanBeBypassed")
+
+        if "ZoneTypeId" in zone:
+            self.zone_type_id = zone["ZoneTypeId"]
+
+        if "Batterylevel" in zone:
+            self.battery_level = zone["Batterylevel"]
+
+        if "Signalstrength" in zone:
+            self.signal_strength = zone["Signalstrength"]
+
+        if "zoneAdditionalInfo" in zone:
+            info = zone["zoneAdditionalInfo"]
+            if info is not None:
+                self.sensor_serial_number = info.get("SensorSerialNumber")
+                self.loop_number = info.get("LoopNumber")
+                self.response_type = info.get("ResponseType")
+                self.alarm_report_state = info.get("AlarmReportState")
+                self.supervision_type = info.get("ZoneSupervisionType")
+                self.chime_state = info.get("ChimeState")
+                self.device_type = info.get("DeviceType")
+
+        return True
+
+    def __str__(self):
+        """Return a string that is printable."""
+        return (
+            f"Zone {self.id} - {self.description}\n"
+            f"  Partition: {self.partition}\t\t"
+            f"Zone Type: {self.zone_type_id}\t"
+            f"CanBeBypassed: {self.can_be_bypassed}\t"
+            f"Status: {self.status}\n"
+            f"  Battery Level: {self.battery_level}\t"
+            f"Signal Stength: {self.signal_strength}\n"
+            f"  Serial Number: {self.sensor_serial_number}\t"
+            f"Loop: {self.loop_number}\t"
+            f"Response Type: {self.response_type}\n"
+            f"  Supervision Type: {self.supervision_type}\t"
+            f"Alarm Report State: {self.alarm_report_state}\n"
+            f"  Chime State: {self.chime_state}\t"
+            f"Device Type: {self.device_type}\n\n"
+        )
+
+    def is_bypassed(self):
+        """Return true if the zone is bypassed."""
+        return self.status & ZONE_STATUS_BYPASSED > 0
+
+    def bypass(self):
+        """Set is_bypassed status."""
+        self.status = ZONE_STATUS_BYPASSED
+
+    def is_faulted(self):
+        """Return true if the zone is faulted."""
+        return self.status & ZONE_STATUS_FAULT > 0
+
+    def is_tampered(self):
+        """Return true if zone is tampered."""
+        return self.status & ZONE_STATUS_TROUBLE > 0
+
+    def is_low_battery(self):
+        """Return true if low battery."""
+        return self.status & ZONE_STATUS_LOW_BATTERY > 0
+
+    def is_troubled(self):
+        """Return true if zone is troubled."""
+        return self.status & ZONE_STATUS_TROUBLE > 0
+
+    def is_triggered(self):
+        """Return true if zone is triggered."""
+        return self.status & ZONE_STATUS_TRIGGERED > 0
+
+    def is_type_button(self):
+        """Return true if zone is a button."""
+
+        # as seen so far, any security zone that cannot be bypassed is a button on a panel
+        if self.zone_type_id == ZONE_TYPE_SECURITY and self.can_be_bypassed == 0:
+            return True
+
+        if self.zone_type_id in (ZONE_TYPE_PROA7_MEDICAL, ZONE_TYPE_PROA7_POLICE):
+            return True
+
+        return False
+
+    def is_type_security(self):
+        """Return true if zone type is security."""
+        return self.zone_type_id in (
+            ZONE_TYPE_SECURITY,
+            ZONE_TYPE_LYRIC_CONTACT,
+            ZONE_TYPE_PROA7_SECURITY,
+            ZONE_TYPE_LYRIC_MOTION,
+            ZONE_TYPE_LYRIC_POLICE,
+            ZONE_TYPE_PROA7_INTERIOR_DELAY,
+            ZONE_TYPE_LYRIC_LOCAL_ALARM,
+        )
+
+    def is_type_motion(self):
+        """Return true if zone type is motion."""
+        return self.zone_type_id == ZONE_TYPE_LYRIC_MOTION
+
+    def is_type_fire(self):
+        """Return true if zone type is fire or smoke."""
+        return self.zone_type_id in (ZONE_TYPE_FIRE_SMOKE, ZONE_TYPE_LYRIC_TEMP)
+
+    def is_type_carbon_monoxide(self):
+        """Return true if zone type is carbon monoxide."""
+        return self.zone_type_id == ZONE_TYPE_CARBON_MONOXIDE
+
+    def is_type_medical(self):
+        """Return true if zone type is medical."""
+        return self.zone_type_id == ZONE_TYPE_PROA7_MEDICAL


Test patch:
diff --git a/tests/common.py b/tests/common.py
index e172e42..4586033 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -6,6 +6,7 @@
     RESPONSE_AUTHENTICATE,
     RESPONSE_DISARMED,
     RESPONSE_GET_ZONE_DETAILS_SUCCESS,
+    RESPONSE_PARTITION_DETAILS,
 )
 
 
@@ -13,6 +14,7 @@ def create_client():
     """Return a TotalConnectClient that appears to be logged in."""
     responses = [
         RESPONSE_AUTHENTICATE,
+        RESPONSE_PARTITION_DETAILS,
         RESPONSE_GET_ZONE_DETAILS_SUCCESS,
         RESPONSE_DISARMED,
     ]
@@ -23,6 +25,6 @@ def create_client():
         client = TotalConnectClient.TotalConnectClient(
             "username", "password", {"123456": "1234"}
         )
-        assert mock_request.call_count == 3
+        assert mock_request.call_count == 4
 
     return client
diff --git a/tests/const.py b/tests/const.py
index d07b760..d8d89bc 100644
--- a/tests/const.py
+++ b/tests/const.py
@@ -1,16 +1,41 @@
 """Testing constants."""
 import TotalConnectClient
 
+from zone import (
+    ZONE_STATUS_NORMAL,
+    ZONE_TYPE_SECURITY,
+    ZONE_STATUS_LOW_BATTERY,
+    ZONE_TYPE_LYRIC_CONTACT,
+    ZONE_TYPE_LYRIC_MOTION,
+    ZONE_TYPE_LYRIC_POLICE,
+    ZONE_TYPE_LYRIC_TEMP,
+    ZONE_TYPE_LYRIC_LOCAL_ALARM,
+)
+from partition import TotalConnectPartition
+
+
 PASSWORD_BAD = "none"
 USERNAME_BAD = "none"
 
+DEVICE_INFO_BASIC_1 = {
+    "DeviceID": 1234567,
+    "DeviceName": "test",
+    "DeviceClassID": 1,
+    "DeviceSerialNumber": "987654321ABC",
+    "DeviceFlags": "PromptForUserCode=0,PromptForInstallerCode=0,PromptForImportSecuritySettings=0,AllowUserSlotEditing=0,CalCapable=1,CanBeSentToPanel=0,CanArmNightStay=0,CanSupportMultiPartition=0,PartitionCount=0,MaxPartitionCount=0,OnBoardingSupport=0,PartitionAdded=0,DuplicateUserSyncStatus=0,PanelType=8,PanelVariant=1,BLEDisarmCapable=0,ArmHomeSupported=0,DuplicateUserCodeCheck=1,CanSupportRapid=0,IsKeypadSupported=1,WifiEnrollmentSupported=0,IsConnectedPanel=0,ArmNightInSceneSupported=0,BuiltInCameraSettingsSupported=0,ZWaveThermostatScheduleDisabled=0,MultipleAuthorityLevelSupported=0,VideoOnPanelSupported=0,EnableBLEMode=0,IsPanelWiFiResetSupported=0,IsCompetitorClearBypass=0,IsNotReadyStateSupported=0,isArmStatusWithoutExitDelayNotSupported=0",
+    "SecurityPanelTypeID": None,
+    "DeviceSerialText": None,
+}
+DEVICE_LIST = []
+DEVICE_LIST.append(DEVICE_INFO_BASIC_1)
+
 LOCATION_INFO_BASIC_NORMAL = {
     "LocationID": "123456",
     "LocationName": "Home",
     "SecurityDeviceID": "987654",
     "PhotoURL": "http://www.example.com/some/path/to/file.jpg",
     "LocationModuleFlags": "Security=1,Video=0,Automation=0,GPS=0,VideoPIR=0",
-    "DeviceList": None,
+    "DeviceList": DEVICE_LIST,
 }
 
 LOCATIONS = {"LocationInfoBasic": [LOCATION_INFO_BASIC_NORMAL]}
@@ -27,7 +52,7 @@
 ZONE_NORMAL = {
     "ZoneID": "1",
     "ZoneDescription": "Normal",
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_NORMAL,
+    "ZoneStatus": ZONE_STATUS_NORMAL,
     "PartitionId": "1",
 }
 
@@ -35,9 +60,9 @@
     "ZoneID": "1",
     "ZoneDescription": "Low Battery",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_LOW_BATTERY,
+    "ZoneStatus": ZONE_STATUS_LOW_BATTERY,
 }
 
 ZONE_INFO = []
@@ -54,28 +79,26 @@
     "Signalstrength": "-1",
     "zoneAdditionalInfo": None,
     "ZoneID": "1",
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_NORMAL,
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneStatus": ZONE_STATUS_NORMAL,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
     "ZoneFlags": None,
 }
 
 ZONE_STATUS_LYRIC_CONTACT = ZONE_STATUS_NORMAL.copy()
-ZONE_STATUS_LYRIC_CONTACT["ZoneTypeId"] = TotalConnectClient.ZONE_TYPE_LYRIC_CONTACT
+ZONE_STATUS_LYRIC_CONTACT["ZoneTypeId"] = ZONE_TYPE_LYRIC_CONTACT
 
 ZONE_STATUS_LYRIC_MOTION = ZONE_STATUS_NORMAL.copy()
-ZONE_STATUS_LYRIC_MOTION["ZoneTypeId"] = TotalConnectClient.ZONE_TYPE_LYRIC_MOTION
+ZONE_STATUS_LYRIC_MOTION["ZoneTypeId"] = ZONE_TYPE_LYRIC_MOTION
 
 ZONE_STATUS_LYRIC_POLICE = ZONE_STATUS_NORMAL.copy()
-ZONE_STATUS_LYRIC_POLICE["ZoneTypeId"] = TotalConnectClient.ZONE_TYPE_LYRIC_POLICE
+ZONE_STATUS_LYRIC_POLICE["ZoneTypeId"] = ZONE_TYPE_LYRIC_POLICE
 
 ZONE_STATUS_LYRIC_TEMP = ZONE_STATUS_NORMAL.copy()
-ZONE_STATUS_LYRIC_TEMP["ZoneTypeId"] = TotalConnectClient.ZONE_TYPE_LYRIC_TEMP
+ZONE_STATUS_LYRIC_TEMP["ZoneTypeId"] = ZONE_TYPE_LYRIC_TEMP
 
 ZONE_STATUS_LYRIC_LOCAL_ALARM = ZONE_STATUS_NORMAL.copy()
-ZONE_STATUS_LYRIC_LOCAL_ALARM[
-    "ZoneTypeId"
-] = TotalConnectClient.ZONE_TYPE_LYRIC_LOCAL_ALARM
+ZONE_STATUS_LYRIC_LOCAL_ALARM["ZoneTypeId"] = ZONE_TYPE_LYRIC_LOCAL_ALARM
 
 
 ZONE_STATUS_INFO = []
@@ -99,6 +122,11 @@
     "ArmingState": TotalConnectClient.TotalConnectLocation.DISARMED,
 }
 
+PARTITION_DISARMED2 = {
+    "PartitionID": "2",
+    "ArmingState": TotalConnectClient.TotalConnectLocation.DISARMED,
+}
+
 PARTITION_ARMED_STAY = {
     "PartitionID": "1",
     "ArmingState": TotalConnectClient.TotalConnectLocation.ARMED_STAY,
@@ -116,6 +144,7 @@
 
 PARTITION_INFO_DISARMED = {}
 PARTITION_INFO_DISARMED[0] = PARTITION_DISARMED
+PARTITION_INFO_DISARMED[1] = PARTITION_DISARMED2
 
 PARTITION_INFO_ARMED_STAY = {}
 PARTITION_INFO_ARMED_STAY[0] = PARTITION_ARMED_STAY
@@ -162,16 +191,30 @@
 METADATA_ARMED_AWAY = METADATA_DISARMED.copy()
 METADATA_ARMED_AWAY["Partitions"] = PARTITIONS_ARMED_AWAY
 
-RESPONSE_DISARMED = {"ResultCode": 0, "PanelMetadataAndStatus": METADATA_DISARMED}
-RESPONSE_ARMED_STAY = {"ResultCode": 0, "PanelMetadataAndStatus": METADATA_ARMED_STAY}
+RESPONSE_DISARMED = {
+    "ResultCode": 0,
+    "ResultData": "Success",
+    "PanelMetadataAndStatus": METADATA_DISARMED,
+    "ArmingState": TotalConnectClient.TotalConnectLocation.DISARMED,
+}
+RESPONSE_ARMED_STAY = {
+    "ResultCode": 0,
+    "ResultData": "Success",
+    "PanelMetadataAndStatus": METADATA_ARMED_STAY,
+}
 RESPONSE_ARMED_STAY_NIGHT = {
     "ResultCode": 0,
     "PanelMetadataAndStatus": METADATA_ARMED_STAY_NIGHT,
 }
-RESPONSE_ARMED_AWAY = {"ResultCode": 0, "PanelMetadataAndStatus": METADATA_ARMED_AWAY}
+RESPONSE_ARMED_AWAY = {
+    "ResultCode": 0,
+    "ResultData": "Success",
+    "PanelMetadataAndStatus": METADATA_ARMED_AWAY,
+}
 
 RESPONSE_AUTHENTICATE = {
     "ResultCode": 0,
+    "ResultData": "Success",
     "SessionID": 1,
     "Locations": LOCATIONS,
     "ModuleFlags": MODULE_FLAGS,
@@ -216,3 +259,24 @@
     "ResultCode": -123456,
     "ResultData": "testing unknown result code",
 }
+
+PARTITION_DETAILS_1 = {
+    "PartitionID": 1,
+    "ArmingState": TotalConnectPartition.DISARMED,
+    "PartitionName": "Test1",
+}
+
+PARTITION_DETAILS_2 = {
+    "PartitionID": 2,
+    "ArmingState": TotalConnectPartition.DISARMED,
+    "PartitionName": "Test2",
+}
+
+PARTITION_DETAILS = []
+PARTITION_DETAILS.append(PARTITION_DETAILS_1)
+
+RESPONSE_PARTITION_DETAILS = {
+    "ResultCode": TotalConnectClient.TotalConnectClient.SUCCESS,
+    "ResultData": "testing partition details",
+    "PartitionsInfoList": PARTITION_DETAILS,
+}
diff --git a/tests/test_client_init.py b/tests/test_client_init.py
index b129a24..a38f33f 100644
--- a/tests/test_client_init.py
+++ b/tests/test_client_init.py
@@ -11,6 +11,7 @@
     RESPONSE_AUTHENTICATE_EMPTY,
     RESPONSE_DISARMED,
     RESPONSE_GET_ZONE_DETAILS_SUCCESS,
+    RESPONSE_PARTITION_DETAILS,
 )
 
 
@@ -30,6 +31,7 @@ def tests_init_usercodes_none(self):
         """Test init with usercodes == None."""
         responses = [
             RESPONSE_AUTHENTICATE,
+            RESPONSE_PARTITION_DETAILS,
             RESPONSE_GET_ZONE_DETAILS_SUCCESS,
             RESPONSE_DISARMED,
         ]
@@ -40,7 +42,7 @@ def tests_init_usercodes_none(self):
             client = TotalConnectClient.TotalConnectClient(
                 "username", "password", usercodes=None
             )
-            assert mock_request.call_count == 3
+            assert mock_request.call_count == 4
 
         assert client.usercodes == {"default": TotalConnectClient.DEFAULT_USERCODE}
 
@@ -64,6 +66,7 @@ def tests_init_usercodes_string(self):
         """Test init with usercodes == a string."""
         responses = [
             RESPONSE_AUTHENTICATE,
+            RESPONSE_PARTITION_DETAILS,
             RESPONSE_GET_ZONE_DETAILS_SUCCESS,
             RESPONSE_DISARMED,
         ]
@@ -74,6 +77,6 @@ def tests_init_usercodes_string(self):
             client = TotalConnectClient.TotalConnectClient(
                 "username", "password", usercodes="123456"
             )
-            assert mock_request.call_count == 3
+            assert mock_request.call_count == 4
 
         assert client.usercodes == {"default": TotalConnectClient.DEFAULT_USERCODE}
diff --git a/tests/test_client_misc.py b/tests/test_client_misc.py
index d3968f6..7131bd9 100644
--- a/tests/test_client_misc.py
+++ b/tests/test_client_misc.py
@@ -15,7 +15,7 @@
     RESPONSE_GET_ZONE_DETAILS_SUCCESS,
     RESPONSE_UNKNOWN,
 )
-from TotalConnectClient import ZONE_STATUS_NORMAL
+from zone import ZONE_STATUS_NORMAL
 
 
 class TestTotalConnectClient(unittest.TestCase):
diff --git a/tests/test_client_request.py b/tests/test_client_request.py
index 1b6fa54..cc912da 100644
--- a/tests/test_client_request.py
+++ b/tests/test_client_request.py
@@ -15,6 +15,7 @@
     RESPONSE_FAILED_TO_CONNECT,
     RESPONSE_GET_ZONE_DETAILS_SUCCESS,
     RESPONSE_INVALID_SESSION,
+    RESPONSE_PARTITION_DETAILS,
     RESPONSE_SESSION_INITIATED,
     RESPONSE_UNKNOWN,
 )
@@ -47,6 +48,7 @@ def tests_request_init(self):
             fakeResponse(
                 RESPONSE_AUTHENTICATE["ResultCode"], "Authentication Succeess."
             ),
+            fakeResponse(RESPONSE_PARTITION_DETAILS["ResultCode"], "Partition Success"),
             fakeResponse(
                 RESPONSE_GET_ZONE_DETAILS_SUCCESS["ResultCode"], "Zone Details Success"
             ),
@@ -54,6 +56,7 @@ def tests_request_init(self):
         ]
         serialize_responses = [
             RESPONSE_AUTHENTICATE,
+            RESPONSE_PARTITION_DETAILS,
             RESPONSE_GET_ZONE_DETAILS_SUCCESS,
             RESPONSE_DISARMED,
         ]
@@ -66,7 +69,7 @@ def tests_request_init(self):
             client = TotalConnectClient.TotalConnectClient(
                 "username", "password", usercodes=None
             )
-            assert mock_request.call_count == 3
+            assert mock_request.call_count == 4
             assert client.is_valid_credentials() is True
             assert client.is_logged_in() is True
 
@@ -172,6 +175,7 @@ def tests_request_invalid_session(self):
             fakeResponse(
                 RESPONSE_AUTHENTICATE["ResultCode"], "Authentication Succeess."
             ),
+            fakeResponse(RESPONSE_PARTITION_DETAILS["ResultCode"], "Partition Success"),
             fakeResponse(
                 RESPONSE_GET_ZONE_DETAILS_SUCCESS["ResultCode"], "Zone Details Success"
             ),
@@ -187,6 +191,7 @@ def tests_request_invalid_session(self):
         # invalid_session responses don't get serialized
         serialize_responses = [
             RESPONSE_AUTHENTICATE,
+            RESPONSE_PARTITION_DETAILS,
             RESPONSE_GET_ZONE_DETAILS_SUCCESS,
             RESPONSE_DISARMED,
             RESPONSE_SESSION_INITIATED,
@@ -201,13 +206,13 @@ def tests_request_invalid_session(self):
             client = TotalConnectClient.TotalConnectClient(
                 "username", "password", usercodes=None
             )
-            assert mock_request.call_count == 3
+            assert mock_request.call_count == 4
             assert client.is_valid_credentials() is True
             assert client.is_logged_in() is True
 
             assert client.arm_away(self.location_id) is True
             # Three original requests + arm_away + authenticate + arm_away
-            assert mock_request.call_count == 6
+            assert mock_request.call_count == 7
 
     def tests_request_unknown_result_code(self):
         """Test an unknown result code."""
diff --git a/tests/test_client_zone_bypass.py b/tests/test_client_zone_bypass.py
index 86445ea..9239a65 100644
--- a/tests/test_client_zone_bypass.py
+++ b/tests/test_client_zone_bypass.py
@@ -6,9 +6,10 @@
 import TotalConnectClient
 from common import create_client
 from const import LOCATION_INFO_BASIC_NORMAL
+from location import TotalConnectLocation
 
 RESPONSE_ZONE_BYPASS_SUCCESS = {
-    "ResultCode": TotalConnectClient.ZONE_BYPASS_SUCCESS,
+    "ResultCode": TotalConnectLocation.ZONE_BYPASS_SUCCESS,
     "ResultData": "None",
 }
 
diff --git a/tests/test_device.py b/tests/test_device.py
new file mode 100644
index 0000000..1151620
--- /dev/null
+++ b/tests/test_device.py
@@ -0,0 +1,15 @@
+"""Test TotalConnectDevice."""
+
+from device import TotalConnectDevice
+
+from const import DEVICE_INFO_BASIC_1
+
+def tests_init():
+    """Test __init__()."""
+    test_device = TotalConnectDevice(DEVICE_INFO_BASIC_1)
+    assert test_device.id == DEVICE_INFO_BASIC_1["DeviceID"]
+
+    # test with missing flags
+    del DEVICE_INFO_BASIC_1["DeviceFlags"]
+    test_device = TotalConnectDevice(DEVICE_INFO_BASIC_1)
+    assert test_device.flags == {}
\ No newline at end of file
diff --git a/tests/test_partition.py b/tests/test_partition.py
new file mode 100644
index 0000000..d5e4200
--- /dev/null
+++ b/tests/test_partition.py
@@ -0,0 +1,83 @@
+"""Test TotalConnectDevice."""
+
+from copy import deepcopy
+from unittest.mock import Mock
+
+from const import PARTITION_DETAILS_1, PARTITION_DISARMED
+from partition import TotalConnectPartition
+
+
+def tests_partition():
+    """Test __init__()."""
+    test_partition = TotalConnectPartition(PARTITION_DETAILS_1, None)
+    assert test_partition.id == 1
+
+    test_partition.update(PARTITION_DISARMED)
+    assert test_partition.arming_state == PARTITION_DISARMED["ArmingState"]
+
+    # remove ArmingState
+    data = deepcopy(PARTITION_DETAILS_1)
+    del data["ArmingState"]
+    assert test_partition.update(data) is False
+
+    # no info
+    assert test_partition.update(None) is False
+
+
+def tests_str():
+    """Test __str__()."""
+    test_partition = TotalConnectPartition(PARTITION_DETAILS_1, None)
+
+    data = (
+        f"PARTITION {PARTITION_DETAILS_1['PartitionID']} - "
+        f"{PARTITION_DETAILS_1['PartitionName']}\n"
+        f"  ArmingState: {PARTITION_DETAILS_1['ArmingState']}\n"
+    )
+
+    assert str(test_partition) == data
+
+
+def tests_arm_disarm():
+    """Test arm and disarm functions."""
+    location = Mock()
+    partition = TotalConnectPartition(PARTITION_DETAILS_1, location)
+
+    location.arm_away.return_value = True
+    assert partition.arm_away() is True
+
+    location.arm_stay.return_value = True
+    assert partition.arm_stay() is True
+
+    location.arm_stay_instant.return_value = True
+    assert partition.arm_stay_instant() is True
+
+    location.arm_away_instant.return_value = True
+    assert partition.arm_away_instant() is True
+
+    location.arm_stay_night.return_value = True
+    assert partition.arm_stay_night() is True
+
+    location.disarm.return_value = True
+    assert partition.disarm() is True
+
+
+def tests_arming_state():
+    """Test status functions."""
+
+    partition = TotalConnectPartition(PARTITION_DETAILS_1, None)
+
+    assert partition.is_disarming() is False
+    assert partition.is_disarmed() is True
+    assert partition.is_arming() is False
+    assert partition.is_armed() is False
+    assert partition.is_armed_away() is False
+    assert partition.is_armed_custom_bypass() is False
+    assert partition.is_armed_home() is False
+    assert partition.is_armed_night() is False
+    assert partition.is_pending() is False
+    assert partition.is_triggered_police() is False
+    assert partition.is_triggered_fire() is False
+    assert partition.is_triggered_gas() is False
+    assert partition.is_triggered() is False
+
+    assert partition.get_armed_status() == TotalConnectPartition.DISARMED
diff --git a/tests/test_zone.py b/tests/test_zone.py
index b8643f0..10dd4e5 100644
--- a/tests/test_zone.py
+++ b/tests/test_zone.py
@@ -1,6 +1,7 @@
 """Test total_connect_client."""
 
 import unittest
+from unittest.runner import TextTestRunner
 
 import pytest
 import TotalConnectClient
@@ -13,37 +14,39 @@
     ZONE_STATUS_NORMAL,
     ZONE_LOW_BATTERY,
 )
-from TotalConnectClient import TotalConnectZone as tcz
+from zone import TotalConnectZone as tcz
+from zone import ZONE_TYPE_SECURITY, ZONE_STATUS_BYPASSED, ZONE_STATUS_FAULT, ZONE_STATUS_TROUBLE, ZONE_STATUS_TRIGGERED,ZONE_TYPE_FIRE_SMOKE, ZONE_TYPE_CARBON_MONOXIDE, ZONE_TYPE_PROA7_MEDICAL
+
 
 ZONE_BYPASSED = {
     "ZoneDescription": "Bypassed",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_BYPASSED,
+    "ZoneStatus": ZONE_STATUS_BYPASSED,
 }
 
 ZONE_FAULTED = {
     "ZoneDescription": "Faulted",
     "ZoneID": "1",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_FAULT,
+    "ZoneStatus": ZONE_STATUS_FAULT,
 }
 
 ZONE_TAMPERED = {
     "ZoneDescription": "Tampered",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_TROUBLE,
+    "ZoneStatus": ZONE_STATUS_TROUBLE,
 }
 
 ZONE_BYPASSED_LOW_BATTERY = {
     "ZoneDescription": "Bypassed Low Battery",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
     "ZoneStatus": 65,
 }
@@ -51,7 +54,7 @@
 ZONE_TROUBLE_LOW_BATTERY = {
     "ZoneDescription": "Trouble Low Battery",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
     "ZoneStatus": 72,
 }
@@ -59,33 +62,33 @@
 ZONE_TRIGGERED = {
     "ZoneDescription": "Triggered",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 1,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_TRIGGERED,
+    "ZoneStatus": ZONE_STATUS_TRIGGERED,
 }
 
 ZONE_BUTTON = {
     "ZoneDescription": "Button",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_SECURITY,
+    "ZoneTypeId": ZONE_TYPE_SECURITY,
     "CanBeBypassed": 0,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_NORMAL,
+    "ZoneStatus": ZONE_STATUS_NORMAL,
 }
 
 ZONE_SMOKE = {
     "ZoneDescription": "Smoke",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_FIRE_SMOKE,
+    "ZoneTypeId": ZONE_TYPE_FIRE_SMOKE,
     "CanBeBypassed": 0,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_NORMAL,
+    "ZoneStatus": ZONE_STATUS_NORMAL,
 }
 
 ZONE_GAS = {
     "ZoneDescription": "Gas",
     "PartitionId": "1",
-    "ZoneTypeId": TotalConnectClient.ZONE_TYPE_CARBON_MONOXIDE,
+    "ZoneTypeId": ZONE_TYPE_CARBON_MONOXIDE,
     "CanBeBypassed": 0,
-    "ZoneStatus": TotalConnectClient.ZONE_STATUS_NORMAL,
+    "ZoneStatus": ZONE_STATUS_NORMAL,
 }
 
 
@@ -287,3 +290,18 @@ def tests_type_motion(self):
         self.assertTrue(self.zone_lyric_motion.is_type_motion())
         self.assertFalse(self.zone_lyric_police.is_type_motion())
         self.assertFalse(self.zone_lyric_temp.is_type_motion())
+
+def test_proa7_zones():
+    """Test ProA7."""
+
+    ZONE_MEDICAL = {
+        "ZoneDescription": "Gas",
+        "PartitionId": "1",
+        "ZoneTypeId": ZONE_TYPE_PROA7_MEDICAL,
+        "CanBeBypassed": 0,
+        "ZoneStatus": ZONE_STATUS_NORMAL,
+    }
+
+    zone = tcz(ZONE_MEDICAL)
+    assert zone.is_type_medical() is True
+    assert zone.is_type_button() is True
diff --git a/total_connect_client/live_test_partitions.py b/total_connect_client/live_test_partitions.py
new file mode 100644
index 0000000..aa06eff
--- /dev/null
+++ b/total_connect_client/live_test_partitions.py
@@ -0,0 +1,26 @@
+"""Test your system from the command line."""
+
+import logging
+import sys
+from pprint import pprint
+
+import TotalConnectClient
+
+logging.basicConfig(filename="test.log", level=logging.DEBUG)
+
+if len(sys.argv) != 5:
+    print("usage:  python3 test.py username password LocationID DeviceID\n")
+    sys.exit()
+
+USERNAME = sys.argv[1]
+PASSWORD = sys.argv[2]
+LOCATION = sys.argv[3]
+DEVICE_ID = sys.argv[4]
+
+TC = TotalConnectClient.TotalConnectClient(USERNAME, PASSWORD)
+
+print("\n\n\nGetPartitionsDetails without partition number\n\n\n")
+
+result = TC.request(f"GetPartitionsDetails(self.token, {LOCATION}, {DEVICE_ID})")
+
+pprint(result)