pax_global_header00006660000000000000000000000064146124704170014520gustar00rootroot0000000000000052 comment=1dee7a4f6d9e312842fa8a30eab4b1191dbc866e pyrisco-0.7.0-rc0/000077500000000000000000000000001461247041700136765ustar00rootroot00000000000000pyrisco-0.7.0-rc0/.gitignore000066400000000000000000000000541461247041700156650ustar00rootroot00000000000000__pycache__/ *.pyc build/ dist/ *.egg-info/pyrisco-0.7.0-rc0/LICENSE000066400000000000000000000020511461247041700147010ustar00rootroot00000000000000MIT License Copyright (c) 2020 On Freund Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.pyrisco-0.7.0-rc0/MANIFEST.in000066400000000000000000000000311461247041700154260ustar00rootroot00000000000000include README.md LICENSEpyrisco-0.7.0-rc0/README.md000066400000000000000000000044731461247041700151650ustar00rootroot00000000000000# PyRisco A python interface to Risco alarm systems through [Risco Cloud](https://riscocloud.com/ELAS/WebUI). ## Installation You can install pyrisco from [PyPI](https://pypi.org/project/pyrisco/): pip3 install pyrisco Python 3.7 and above are supported. ## How to use ### Cloud ```python from pyrisco import RiscoCloud r = RiscoCloud("", "", "") # you can also pass your own session to login. It will not be closed await r.login() alarm = await r.get_state() # partitions and zones are zero-based in Cloud print(alarm.partitions[0].armed) events = await r.get_events("2020-06-17T00:00:00Z", 10) print(events[0].name) print(alarm.zones[0].name) print(alarm.zones[0].triggered) print(alarm.zones[0].bypassed) # arm partition 0 await r.partitions[0].arm() # and disarm it await r.partitions[0].disarm() # Partial arming await r.partitions[0].partial_arm() # Group arming await r.partitions[0].group_arm("B") # or a zero based index await r.partitions[0].group_arm(1) # Don't forget to close when you're done await r.close() ``` ### Local ```python from pyrisco import RiscoLocal r = RiscoLocal("", , "") await r.connect() # Register handlers async def _error(error): print(f'Error handler: {error}') remove_error = r.add_error_handler(_error) async def _event(event): print(f'Event handler: {event}') remove_event = r.add_event_handler(_event) async def _default(command, result, *params): print(f'Default handler: {command}, {result}, {params}') remove_default = r.add_default_handler(_default) async def _zone(zone_id, zone): print(f'Zone handler: {zone_id}, {vars(zone)}') remove_zone = r.add_zone_handler(_zone) async def _partition(partition_id, partition): print(f'Partition handler: {partition_id}, {vars(partition)}') remove_partition = r.add_partition_handler(_partition) await r.connect() # partitions and zones are one-based in Cloud print(r.partitions[1].armed) print(r.zones[1].name) print(r.zones[1].triggered) print(r.zones[1].bypassed) # arm partition 1 await r.partitions[1].arm() # and disarm it await r.partitions[1].disarm() # Partial arming await r.partitions[1].partial_arm() # Group arming await r.partitions[1].group_arm("B") # or a zero based index await r.partitions[1].group_arm(1) # Don't forget to close when you're done await r.disconnect() ```pyrisco-0.7.0-rc0/pyrisco/000077500000000000000000000000001461247041700153665ustar00rootroot00000000000000pyrisco-0.7.0-rc0/pyrisco/__init__.py000066400000000000000000000002241461247041700174750ustar00rootroot00000000000000from pyrisco.local import RiscoLocal from pyrisco.cloud import RiscoCloud from .common import CannotConnectError, OperationError, UnauthorizedError pyrisco-0.7.0-rc0/pyrisco/cloud/000077500000000000000000000000001461247041700164745ustar00rootroot00000000000000pyrisco-0.7.0-rc0/pyrisco/cloud/__init__.py000066400000000000000000000000441461247041700206030ustar00rootroot00000000000000from .risco_cloud import RiscoCloud pyrisco-0.7.0-rc0/pyrisco/cloud/alarm.py000066400000000000000000000012571461247041700201470ustar00rootroot00000000000000from .partition import Partition from .zone import Zone class Alarm: """A representation of a Risco alarm system.""" def __init__(self, api, raw): """Read alarm from response.""" self._api = api self._raw = raw self._partitions = None self._zones = None @property def partitions(self): """Alarm partitions.""" if self._partitions is None: self._partitions = {p["id"]: Partition(self._api, p) for p in self._raw["partitions"]} return self._partitions @property def zones(self): """Alarm zones.""" if self._zones is None: self._zones = {z["zoneID"]: Zone(self._api, z) for z in self._raw["zones"]} return self._zones pyrisco-0.7.0-rc0/pyrisco/cloud/event.py000066400000000000000000000035041461247041700201710ustar00rootroot00000000000000from pyrisco.common import GROUP_ID_TO_NAME EVENT_IDS_TO_TYPES = { 3: "triggered", 9: "zone bypassed", 10: "zone unbypassed", 13: "armed", 16: "disarmed", 28: "power lost", 29: "power restored", 34: "media lost", 35: "media restore", 36: "service needed", 118: "group arm", 119: "group arm", 120: "group arm", 121: "group arm", } class Event: """A representation of a Risco event.""" def __init__(self, raw): """Read event from response.""" self._raw = raw @property def raw(self): return self._raw @property def type_id(self): return self.raw["eventId"] @property def type_name(self): return EVENT_IDS_TO_TYPES.get(self.type_id, "unknown"), @property def partition_id(self): partition_id = self.raw["partAssociationCSV"] if partition_id is None: return None return int(partition_id) @property def time(self): """Time the event was fired.""" return self.raw["logTime"] @property def text(self): """Event text.""" return self.raw["eventText"] @property def name(self): """Event name.""" return self.raw["eventName"] @property def category_id(self): """Event group number.""" return self.raw["group"] @property def category_name(self): """Event group number.""" return self.raw["groupName"] @property def zone_id(self): if self.raw["sourceType"] == 1: return self.raw["sourceID"] - 1 return None @property def user_id(self): if self.raw["sourceType"] == 2: return self.raw["sourceID"] return None @property def group(self): if self.type_id in range(118, 122): return GROUP_ID_TO_NAME[self.type_id - 118] return None @property def priority(self): return self.raw["priority"] @property def source_id(self): return self._source_id pyrisco-0.7.0-rc0/pyrisco/cloud/partition.py000066400000000000000000000027741461247041700210710ustar00rootroot00000000000000from pyrisco.common import GROUP_ID_TO_NAME, Partition as BasePartition class Partition(BasePartition): """A representation of a Risco partition.""" def __init__(self, api, raw): """Read partition from response.""" self._api = api self._raw = raw async def disarm(self): return await self._api.disarm(self.id) async def arm(self): return await self._api.arm(self.id) async def partial_arm(self): return await self._api.partial_arm(self.id) async def group_arm(self, group): return await self._api.group_arm(self.id, group) @property def id(self): """Partition ID number.""" return self._raw["id"] @property def disarmed(self): """Is the partition disarmed.""" return self._raw["armedState"] == 1 @property def partially_armed(self): """Is the partition partially-armed.""" return self._raw["armedState"] == 2 @property def armed(self): """Is the partition armed.""" return self._raw["armedState"] == 3 @property def triggered(self): """Is the partition triggered.""" return self._raw["alarmState"] == 1 @property def exit_timeout(self): """Time remaining till armed.""" return self._raw["exitDelayTO"] @property def arming(self): """Is the partition arming.""" return self.exit_timeout > 0 @property def groups(self): """Group arming status.""" if self._raw.get("groups") is None: return {} return {GROUP_ID_TO_NAME[g["id"]]: g["state"] == 3 for g in self._raw["groups"]} pyrisco-0.7.0-rc0/pyrisco/cloud/risco_cloud.py000066400000000000000000000137351461247041700213640ustar00rootroot00000000000000"""Implementation of a Risco Cloud connection.""" import aiohttp import asyncio from .alarm import Alarm from .event import Event from pyrisco.common import UnauthorizedError, CannotConnectError, OperationError, GROUP_ID_TO_NAME LOGIN_URL = "https://www.riscocloud.com/webapi/api/auth/login" SITE_URL = "https://www.riscocloud.com/webapi/api/wuws/site/GetAll" PIN_URL = "https://www.riscocloud.com/webapi/api/wuws/site/%s/Login" STATE_URL = "https://www.riscocloud.com/webapi/api/wuws/site/%s/ControlPanel/GetState" CONTROL_URL = "https://www.riscocloud.com/webapi/api/wuws/site/%s/ControlPanel/PartArm" EVENTS_URL = ( "https://www.riscocloud.com/webapi/api/wuws/site/%s/ControlPanel/GetEventLog" ) BYPASS_URL = "https://www.riscocloud.com/webapi/api/wuws/site/%s/ControlPanel/SetZoneBypassStatus" NUM_RETRIES = 3 class RiscoCloud: """A connection to a Risco alarm system.""" def __init__(self, username, password, pin, language="en"): """Initialize the object.""" self._username = username self._password = password self._pin = pin self._language = language self._access_token = None self._session_id = None self._site_id = None self._site_name = None self._site_uuid = None self._session = None self._created_session = False async def _authenticated_post(self, url, body): headers = { "Content-Type": "application/json", "authorization": "Bearer " + self._access_token, } async with self._session.post(url, headers=headers, json=body) as resp: json = await resp.json() if json["status"] == 401: raise UnauthorizedError(json["errorText"]) if "result" in json and json["result"] != 0: raise OperationError(str(json)) return json["response"] async def _site_post(self, url, body): site_url = url % self._site_id for i in range(NUM_RETRIES): try: site_body = { **body, "fromControlPanel": True, "sessionToken": self._session_id, } return await self._authenticated_post(site_url, site_body) except UnauthorizedError: if i + 1 == NUM_RETRIES: raise await self.close() await self.login() async def _login_user_pass(self): headers = {"Content-Type": "application/json"} body = {"userName": self._username, "password": self._password} try: async with self._session.post( LOGIN_URL, headers=headers, json=body ) as resp: json = await resp.json() if json["status"] == 401: raise UnauthorizedError("Invalid username or password") self._access_token = json["response"].get("accessToken") except aiohttp.client_exceptions.ClientConnectorError as e: raise CannotConnectError from e if not self._access_token: raise UnauthorizedError("Invalid username or password") async def _login_site(self): resp = await self._authenticated_post(SITE_URL, {}) self._site_id = resp[0]["id"] self._site_name = resp[0]["name"] self._site_uuid = resp[0]["siteUUID"] async def _login_session(self): body = {"languageId": self._language, "pinCode": self._pin} url = PIN_URL % self._site_id resp = await self._authenticated_post(url, body) self._session_id = resp["sessionId"] async def _init_session(self, session): await self.close() if self._session is None: if session is None: self._session = aiohttp.ClientSession() self._created_session = True else: self._session = session async def _send_control_command(self, body): resp = await self._site_post(CONTROL_URL, body) return Alarm(self, resp) async def close(self): """Close the connection.""" self._session_id = None if self._created_session == True and self._session is not None: await self._session.close() self._session = None self._created_session = False async def login(self, session=None): """Login to Risco Cloud.""" if self._session_id: return await self._init_session(session) await self._login_user_pass() await self._login_site() await self._login_session() async def get_state(self): """Get partitions and zones.""" resp = await self._site_post(STATE_URL, {}) return Alarm(self, resp["state"]["status"]) async def disarm(self, partition): """Disarm the alarm.""" body = { "partitions": [{"id": partition, "armedState": 1}], } return await self._send_control_command(body) async def arm(self, partition): """Arm the alarm.""" body = { "partitions": [{"id": partition, "armedState": 3}], } return await self._send_control_command(body) async def partial_arm(self, partition): """Partially-arm the alarm.""" body = { "partitions": [{"id": partition, "armedState": 2}], } return await self._send_control_command(body) async def group_arm(self, partition, group): """Arm a specific group.""" if isinstance(group, str): group = GROUP_ID_TO_NAME.index(group) body = { "partitions": [{"id": partition, "groups": [{"id": group, "state": 3}]}], } return await self._send_control_command(body) async def get_events(self, newer_than, count=10): """Get event log.""" body = { "count": count, "newerThan": newer_than, "offset": 0, } response = await self._site_post(EVENTS_URL, body) return [Event(e) for e in response["controlPanelEventsList"]] async def bypass_zone(self, zone, bypass): """Bypass or unbypass a zone.""" status = 2 if bypass else 3 body = {"zones": [{"trouble": 0, "ZoneID": zone, "Status": status}]} resp = await self._site_post(BYPASS_URL, body) return Alarm(self, resp) @property def site_id(self): """Site ID of the Alarm instance.""" return self._site_id @property def site_name(self): """Site name of the Alarm instance.""" return self._site_name @property def site_uuid(self): """Site UUID of the Alarm instance.""" return self._site_uuid pyrisco-0.7.0-rc0/pyrisco/cloud/zone.py000066400000000000000000000014261461247041700200240ustar00rootroot00000000000000from pyrisco.common import GROUP_ID_TO_NAME, Zone as BaseZone class Zone(BaseZone): """A representation of a Risco zone.""" def __init__(self, api, raw): """Read zone from response.""" self._api = api self._raw = raw async def bypass(self, bypass): return await self._api.bypass_zone(self.id, bypass) @property def id(self): """Zone ID number.""" return self._raw["zoneID"] @property def name(self): """Zone name.""" return self._raw["zoneName"] @property def type(self): """Zone type.""" return self._raw["zoneType"] @property def triggered(self): """Is the zone triggered.""" return self._raw["status"] == 1 @property def bypassed(self): """Is the zone bypassed.""" return self._raw["status"] == 2 pyrisco-0.7.0-rc0/pyrisco/common.py000066400000000000000000000055131461247041700172340ustar00rootroot00000000000000GROUP_ID_TO_NAME = ["A", "B", "C", "D"] class Partition: """A representation of a Risco partition.""" async def disarm(self): """Disarm the partition.""" raise NotImplementedError async def arm(self): """Arm the partition.""" raise NotImplementedError async def partial_arm(self): """Partially-arm the partition.""" raise NotImplementedError async def group_arm(self, group): """Arm a group on the partition.""" raise NotImplementedError @property def id(self): """Partition ID number.""" raise NotImplementedError @property def disarmed(self): """Is the partition disarmed.""" raise NotImplementedError @property def partially_armed(self): """Is the partition partially-armed.""" raise NotImplementedError @property def armed(self): """Is the partition armed.""" raise NotImplementedError @property def triggered(self): """Is the partition triggered.""" raise NotImplementedError @property def exit_timeout(self): """Time remaining till armed.""" raise NotImplementedError @property def arming(self): """Is the partition arming.""" raise NotImplementedError @property def groups(self): """Group arming status.""" raise NotImplementedError class Zone: """A representation of a Risco zone.""" async def bypass(self, bypass): """Bypass or unbypass the zone.""" raise NotImplementedError @property def id(self): raise NotImplementedError @property def name(self): raise NotImplementedError @property def type(self): raise NotImplementedError @property def triggered(self): raise NotImplementedError @property def bypassed(self): raise NotImplementedError class System: """A representation of a Risco System.""" @property def name(self): """System name.""" raise NotImplementedError @property def low_battery_trouble(self): raise NotImplementedError @property def ac_trouble(self): raise NotImplementedError @property def monitoring_station_1_trouble(self): raise NotImplementedError @property def monitoring_station_2_trouble(self): raise NotImplementedError @property def monitoring_station_3_trouble(self): raise NotImplementedError @property def phone_line_trouble(self): raise NotImplementedError @property def clock_trouble(self): raise NotImplementedError @property def box_tamper(self): raise NotImplementedError @property def programming_mode(self): raise NotImplementedError class UnauthorizedError(Exception): """Exception to indicate an error in authorization.""" class CannotConnectError(Exception): """Exception to indicate an error in authorization.""" class OperationError(Exception): """Exception to indicate an error in operation.""" pyrisco-0.7.0-rc0/pyrisco/local/000077500000000000000000000000001461247041700164605ustar00rootroot00000000000000pyrisco-0.7.0-rc0/pyrisco/local/__init__.py000066400000000000000000000000441461247041700205670ustar00rootroot00000000000000from .risco_local import RiscoLocal pyrisco-0.7.0-rc0/pyrisco/local/const.py000066400000000000000000000002411461247041700201550ustar00rootroot00000000000000PANEL_TYPE = "panel_type" PANEL_MODEL = "panel_mode" PANEL_FW = "panel_firmware" MAX_ZONES = "max_zones" MAX_PARTS = "max_partitions" MAX_OUTPUTS = "max_outputs"pyrisco-0.7.0-rc0/pyrisco/local/panels.py000066400000000000000000000036611461247041700203220ustar00rootroot00000000000000from .const import PANEL_TYPE, PANEL_MODEL, PANEL_FW, MAX_ZONES, MAX_PARTS, MAX_OUTPUTS def _rw032_capabilities(firmware): return { PANEL_MODEL: 'Agility 4', MAX_ZONES: 32, MAX_PARTS: 3, MAX_OUTPUTS: 4, } def _rw132_capabilities(firmware): return { PANEL_MODEL: 'Agility', MAX_ZONES: 36, MAX_PARTS: 3, MAX_OUTPUTS: 4, } def _rw232_capabilities(firmware): return { PANEL_MODEL: 'WiComm', MAX_ZONES: 36, MAX_PARTS: 3, MAX_OUTPUTS: 4, } def _rw332_capabilities(firmware): return { PANEL_MODEL: 'WiCommPro', MAX_ZONES: 36, MAX_PARTS: 3, MAX_OUTPUTS: 4, } def _rp432_capabilities(firmware): max_zones = 32 max_outputs = 14 parts = firmware.split('.') if int(parts[0]) >= 3: max_zones = 50 max_outputs = 32 return { PANEL_MODEL: 'LightSys', MAX_ZONES: max_zones, MAX_PARTS: 4, MAX_OUTPUTS: max_outputs, } def _rp432mp_capabilities(firmware): return { PANEL_MODEL: 'LightSys+', MAX_ZONES: 512, MAX_PARTS: 32, MAX_OUTPUTS: 196, } def _rp512_capabilities(firmware): max_zones = 64 parts = list(map(int, firmware.split('.'))) if ((parts[0] > 1) or (parts[0] == 1 and parts[1] > 2) or (parts[0] == 1 and parts[1] == 2 and parts[2] > 0) or (parts[0] == 1 and parts[1] == 2 and parts[2] == 0 and parts[3] >= 7)): max_zones = 128; return { PANEL_MODEL: 'ProsysPlus|GTPlus', MAX_ZONES: max_zones, MAX_PARTS: 32, MAX_OUTPUTS: 262, }; PANELS = { 'RW032': _rw032_capabilities, 'RW132': _rw132_capabilities, 'RW232': _rw232_capabilities, 'RW332': _rw332_capabilities, 'RP432': _rp432_capabilities, 'RP432MP': _rp432mp_capabilities, 'RP512': _rp512_capabilities } def panel_capabilities(panel_type, firmware): normalized = panel_type.split(":")[0] firmware = firmware.split(" ")[0] caps = PANELS[normalized](firmware) return {**caps, **{PANEL_TYPE: panel_type, PANEL_FW: firmware}} pyrisco-0.7.0-rc0/pyrisco/local/partition.py000066400000000000000000000034711461247041700210500ustar00rootroot00000000000000from pyrisco.common import GROUP_ID_TO_NAME, Partition as BasePartition class Partition(BasePartition): def __init__(self, panel, partition_id, label, status): """Read partition from response.""" self._panel = panel self._id = partition_id self._status = status self._name = label.strip() async def disarm(self): """Disarm the partition.""" return await self._panel.disarm(self.id) async def arm(self): """Arm the partition.""" return await self._panel.arm(self.id) async def partial_arm(self): """Partially-arm the partition.""" return await self._panel.partial_arm(self.id) async def group_arm(self, group): """Arm a group on the partition.""" return await self._panel.group_arm(self.id, group) @property def id(self): """Partition ID number.""" return self._id @property def name(self): """Partition name.""" return self._name @property def disarmed(self): """Is the partition disarmed.""" return not (self.armed or self.partially_armed) @property def partially_armed(self): """Is the partition partially-armed.""" return 'H' in self._status @property def armed(self): """Is the partition armed.""" return 'A' in self._status @property def triggered(self): """Is the partition triggered.""" return 'a' in self._status @property def ready(self): """Is the partition ready.""" return 'R' in self._status @property def arming(self): """Is the partition arming.""" # return self.disarmed and not self.ready return False @property def groups(self): """Group arming status.""" return {GROUP_ID_TO_NAME[g]: (str(g+1) in self._status) for g in range(0,4)} def update_status(self, status): self._status = status pyrisco-0.7.0-rc0/pyrisco/local/risco_crypt.py000066400000000000000000000123651461247041700214010ustar00rootroot00000000000000import base64 CRC_ARRAY_BASE64 = 'WzAsNDkzNDUsNDk1MzcsMzIwLDQ5OTIxLDk2MCw2NDAsNDk3MjksNTA2ODksMTcyOCwxOTIwLDUxMDA5LDEyODAsNTA2MjUsNTAzMDUsMTA4OCw1MjIyNSwzMjY0LDM0NTYsNTI1NDUsMzg0MCw1MzE4NSw1Mjg2NSwzNjQ4LDI1NjAsNTE5MDUsNTIwOTcsMjg4MCw1MTQ1NywyNDk2LDIxNzYsNTEyNjUsNTUyOTcsNjMzNiw2NTI4LDU1NjE3LDY5MTIsNTYyNTcsNTU5MzcsNjcyMCw3NjgwLDU3MDI1LDU3MjE3LDgwMDAsNTY1NzcsNzYxNiw3Mjk2LDU2Mzg1LDUxMjAsNTQ0NjUsNTQ2NTcsNTQ0MCw1NTA0MSw2MDgwLDU3NjAsNTQ4NDksNTM3NjEsNDgwMCw0OTkyLDU0MDgxLDQzNTIsNTM2OTcsNTMzNzcsNDE2MCw2MTQ0MSwxMjQ4MCwxMjY3Miw2MTc2MSwxMzA1Niw2MjQwMSw2MjA4MSwxMjg2NCwxMzgyNCw2MzE2OSw2MzM2MSwxNDE0NCw2MjcyMSwxMzc2MCwxMzQ0MCw2MjUyOSwxNTM2MCw2NDcwNSw2NDg5NywxNTY4MCw2NTI4MSwxNjMyMCwxNjAwMCw2NTA4OSw2NDAwMSwxNTA0MCwxNTIzMiw2NDMyMSwxNDU5Miw2MzkzNyw2MzYxNywxNDQwMCwxMDI0MCw1OTU4NSw1OTc3NywxMDU2MCw2MDE2MSwxMTIwMCwxMDg4MCw1OTk2OSw2MDkyOSwxMTk2OCwxMjE2MCw2MTI0OSwxMTUyMCw2MDg2NSw2MDU0NSwxMTMyOCw1ODM2OSw5NDA4LDk2MDAsNTg2ODksOTk4NCw1OTMyOSw1OTAwOSw5NzkyLDg3MDQsNTgwNDksNTgyNDEsOTAyNCw1NzYwMSw4NjQwLDgzMjAsNTc0MDksNDA5NjEsMjQ3NjgsMjQ5NjAsNDEyODEsMjUzNDQsNDE5MjEsNDE2MDEsMjUxNTIsMjYxMTIsNDI2ODksNDI4ODEsMjY0MzIsNDIyNDEsMjYwNDgsMjU3MjgsNDIwNDksMjc2NDgsNDQyMjUsNDQ0MTcsMjc5NjgsNDQ4MDEsMjg2MDgsMjgyODgsNDQ2MDksNDM1MjEsMjczMjgsMjc1MjAsNDM4NDEsMjY4ODAsNDM0NTcsNDMxMzcsMjY2ODgsMzA3MjAsNDcyOTcsNDc0ODksMzEwNDAsNDc4NzMsMzE2ODAsMzEzNjAsNDc2ODEsNDg2NDEsMzI0NDgsMzI2NDAsNDg5NjEsMzIwMDAsNDg1NzcsNDgyNTcsMzE4MDgsNDYwODEsMjk4ODgsMzAwODAsNDY0MDEsMzA0NjQsNDcwNDEsNDY3MjEsMzAyNzIsMjkxODQsNDU3NjEsNDU5NTMsMjk1MDQsNDUzMTMsMjkxMjAsMjg4MDAsNDUxMjEsMjA0ODAsMzcwNTcsMzcyNDksMjA4MDAsMzc2MzMsMjE0NDAsMjExMjAsMzc0NDEsMzg0MDEsMjIyMDgsMjI0MDAsMzg3MjEsMjE3NjAsMzgzMzcsMzgwMTcsMjE1NjgsMzk5MzcsMjM3NDQsMjM5MzYsNDAyNTcsMjQzMjAsNDA4OTcsNDA1NzcsMjQxMjgsMjMwNDAsMzk2MTcsMzk4MDksMjMzNjAsMzkxNjksMjI5NzYsMjI2NTYsMzg5NzcsMzQ4MTcsMTg2MjQsMTg4MTYsMzUxMzcsMTkyMDAsMzU3NzcsMzU0NTcsMTkwMDgsMTk5NjgsMzY1NDUsMzY3MzcsMjAyODgsMzYwOTcsMTk5MDQsMTk1ODQsMzU5MDUsMTc0MDgsMzM5ODUsMzQxNzcsMTc3MjgsMzQ1NjEsMTgzNjgsMTgwNDgsMzQzNjksMzMyODEsMTcwODgsMTcyODAsMzM2MDEsMTY2NDAsMzMyMTcsMzI4OTcsMTY0NDhd' ENCRYPTION_FLAG_INDEX = 1 ENCRYPTION_FLAG_VALUE = 17 START = b'\x02' END = b'\x03' DLE = b'\x10' ESCAPED_START = DLE + START ESCAPED_END = DLE + END ESCAPED_DLE = DLE + DLE def _is_encrypted(message): return message[ENCRYPTION_FLAG_INDEX] == ENCRYPTION_FLAG_VALUE class RiscoCrypt: def __init__(self, encoding='utf-8'): self._pseudo_buffer = None self._crc_decoded = list(map(int, base64.b64decode(CRC_ARRAY_BASE64).decode("utf-8")[1:-1].split(','))) self.encrypted_panel = False self._encoding = encoding def set_panel_id(self, panel_id): self._pseudo_buffer = RiscoCrypt._create_pseudo_buffer(panel_id) def encode(self, cmd_id, command, force_crypt=False): encrypted = bytearray() encrypted.append(2) encrypt = force_crypt or self.encrypted_panel if encrypt: encrypted.append(17) full_cmd = f'{cmd_id:02d}{command}\x17' crc = self._get_crc(full_cmd) full_cmd += crc chars = self._encrypt_chars(full_cmd.encode(self._encoding), encrypt) encrypted += chars encrypted.append(3) return encrypted; def decode(self, chars): self.encrypted_panel = _is_encrypted(chars) decrypted_chars = self._decrypt_chars(chars) decrypted = decrypted_chars.decode(self._encoding) raw_command = decrypted[0:decrypted.index('\x17')+1] command, crc = decrypted.split('\x17') if command[0] in ['N','B']: cmd_id = None command_string = command else: cmd_id = int(command[:2]) command_string = command[2:] return [cmd_id, command_string, self._valid_crc(raw_command, crc)] def _encrypt_chars(self, chars, encrypt): position = 0; if encrypt: chars = bytearray(map(self._encrypt_decrypt_char, chars, range(len(chars)))) chars = chars.replace(DLE, ESCAPED_DLE) chars = chars.replace(START, ESCAPED_START) chars = chars.replace(END, ESCAPED_END) return chars def _decrypt_chars(self, chars): decrypt = _is_encrypted(chars) initial_index = 2 if decrypt else 1 escaped = chars[initial_index:-1] escaped = escaped.replace(ESCAPED_DLE, DLE) escaped = escaped.replace(ESCAPED_START, START) escaped = escaped.replace(ESCAPED_END, END) if decrypt: return bytes(map(self._encrypt_decrypt_char, escaped, range(len(escaped)))) else: return escaped def _encrypt_decrypt_char(self, char, position): return char ^ self._pseudo_buffer[position] def _create_pseudo_buffer(panel_id): buffer_length = 255 pseudo_buffer = bytearray(buffer_length) if panel_id == 0: return pseudo_buffer pid = panel_id num_array = [2, 4, 16, 32768] for i in range(buffer_length): n1 = 0 n2 = 0 for n1 in range(4): if (pid & num_array[n1]) > 0: n2 ^= 1 pid = pid << 1 | n2 pseudo_buffer[i] = (pid & buffer_length) return pseudo_buffer def _valid_crc(self, command, crc): if len(crc) != 4: return False for char in crc: if ord(char) > 127: return False computed = self._get_crc(command) return computed == crc def _get_crc(self, command): crc_base = 65535 byte_buffer = bytearray(command, self._encoding) for b in byte_buffer: crc_base = crc_base >> 8 ^ self._crc_decoded[crc_base & 255 ^ b] return f'{crc_base:04X}' pyrisco-0.7.0-rc0/pyrisco/local/risco_local.py000066400000000000000000000162701461247041700213310ustar00rootroot00000000000000import asyncio import copy from .const import PANEL_TYPE, PANEL_MODEL, PANEL_FW, MAX_ZONES, MAX_PARTS, MAX_OUTPUTS from .panels import panel_capabilities from .partition import Partition from .zone import Zone from .system import System from .risco_socket import RiscoSocket from pyrisco.common import OperationError, GROUP_ID_TO_NAME class RiscoLocal: def __init__(self, host, port, code, **kwargs): self._rs = RiscoSocket(host, port, code, **kwargs) self._panel_capabilities = None self._listen_task = None self._system_handlers = [] self._zone_handlers = [] self._partition_handlers = [] self._error_handlers = [] self._default_handlers = [] self._event_handlers = [] self._system = None self._zones = None self._partitions = None self._id = None self._legacy_panel = False async def connect(self): await self._rs.connect() panel_type = await self._rs.send_result_command("PNLCNF") self._legacy_panel = not panel_type.startswith("RP") if self._legacy_panel: firmware = "" else: firmware = await self._rs.send_result_command("FSVER?") self._panel_capabilities = panel_capabilities(panel_type, firmware) self._id = await self._rs.send_result_command("PNLSERD") self._system = await self._init_system() self._zones = await self._init_zones() self._partitions = await self._init_partitions() self._listen_task = asyncio.create_task(self._listen(self._rs.queue)) async def disconnect(self): await self._rs.disconnect() self._listen_task.cancel() self._listen_task = None def add_error_handler(self, handler): return RiscoLocal._add_handler(self._error_handlers, handler) def add_event_handler(self, handler): return RiscoLocal._add_handler(self._event_handlers, handler) def add_system_handler(self, handler): return RiscoLocal._add_handler(self._system_handlers, handler) def add_zone_handler(self, handler): return RiscoLocal._add_handler(self._zone_handlers, handler) def add_partition_handler(self, handler): return RiscoLocal._add_handler(self._partition_handlers, handler) def add_default_handler(self, handler): return RiscoLocal._add_handler(self._default_handlers, handler) @property def id(self): return self._id @property def zones(self): return self._zones @property def partitions(self): return self._partitions @property def system(self): return self._system async def disarm(self, partition_id): """Disarm a partition.""" return await self._rs.send_ack_command(f'DISARM={partition_id}') async def arm(self, partition_id): """Arm a partition.""" return await self._rs.send_ack_command(f'ARM={partition_id}') async def partial_arm(self, partition_id): """Partially-arm a partition.""" return await self._rs.send_ack_command(f'STAY={partition_id}') async def group_arm(self, partition_id, group): """Arm a specific group on a partition.""" if isinstance(group, str): group = GROUP_ID_TO_NAME.index(group) + 1 return await self._rs.send_ack_command(f'GARM*{group}={partition_id}') async def bypass_zone(self, zone_id, bypass): """Bypass or unbypass a zone.""" if self.zones[zone_id].bypassed != bypass: await self._rs.send_ack_command(F'ZBYPAS={zone_id}') def _add_handler(handlers, handler): handlers.append(handler) def _remove(): handlers.remove(handler) return _remove async def _init_system(self): try: label = await self._rs.send_result_command_limited(f'SYSLBL?') status = await self._rs.send_result_command_limited(f'SSTT?') except OperationError: return None return System(self, label, status) async def _init_partitions(self): return await self._get_objects(1, self._panel_capabilities[MAX_PARTS], self._create_partition) async def _init_zones(self): return await self._get_objects(1, self._panel_capabilities[MAX_ZONES], self._create_zone) async def _get_objects(self, min, max, func): ids = range(min, min+max) temp = await asyncio.gather(*[func(i) for i in ids]) return { o.id: o for o in temp if o } async def _create_partition(self, partition_id): try: status = await self._rs.send_result_command_limited(f'PSTT{partition_id}?') if not 'E' in status: return None label = await self._rs.send_result_command_limited(f'PLBL{partition_id}?') except OperationError: return None return Partition(self, partition_id, label, status) async def _create_zone(self, zone_id): try: zone_type = int(await self._rs.send_result_command_limited(f'ZTYPE*{zone_id}?')) if zone_type == 0: return None if self._legacy_panel: tech = '' else: tech = await self._rs.send_result_command_limited(f'ZLNKTYP{zone_id}?') if tech.strip() == 'N': return None status = await self._rs.send_result_command_limited(f'ZSTT*{zone_id}?') if status.endswith('N'): return None label = await self._rs.send_result_command_limited(f'ZLBL*{zone_id}?') partitions = await self._rs.send_result_command_limited(f'ZPART&*{zone_id}?') if self._legacy_panel: groups = '0' else: groups = await self._rs.send_result_command_limited(f'ZAREA&*{zone_id}?') return Zone(self, zone_id, status, zone_type, label, partitions, groups, tech) except OperationError: return None def _system_status(self, status): self._system.update_status(status) RiscoLocal._call_handlers(self._system_handlers, copy.copy(self._system)) def _zone_status(self, zone_id, status): z = self._zones[zone_id] z.update_status(status) RiscoLocal._call_handlers(self._zone_handlers, zone_id, copy.copy(z)) def _partition_status(self, partition_id, status): p = self._partitions[partition_id] p.update_status(status) RiscoLocal._call_handlers(self._partition_handlers, partition_id, copy.copy(p)) def _default(self, command, result, *params): RiscoLocal._call_handlers(self._default_handlers, command, result, *params) def _event(self, event): RiscoLocal._call_handlers(self._event_handlers, event) def _error(self, error): RiscoLocal._call_handlers(self._error_handlers, error) def _call_handlers(handlers, *params): if len(handlers) > 0: async def _gather(): await asyncio.gather(*[h(*params) for h in handlers]) asyncio.create_task(_gather()) async def _listen(self, queue): while True: try: item = await queue.get() if isinstance(item, Exception): self._error(item) continue if item.startswith('CLOCK'): # safe to ignore these continue if item.startswith("EVENT="): self._event(item[6:]) continue command, result, *params = item.split("=") if command.startswith('ZSTT'): self._zone_status(int(command[4:]), result) elif command.startswith('PSTT'): self._partition_status(int(command[4:]), result) elif command.startswith('SSTT'): self._system_status(result) else: self._default(command, result, *params) except Exception as error: self._error(error) pyrisco-0.7.0-rc0/pyrisco/local/risco_socket.py000066400000000000000000000121221461247041700215170ustar00rootroot00000000000000import asyncio from .risco_crypt import RiscoCrypt, ESCAPED_END, END from pyrisco.common import UnauthorizedError, CannotConnectError, OperationError MIN_CMD_ID = 1 MAX_CMD_ID = 49 class RiscoSocket: def __init__(self, host, port, code, **kwargs): self._host = host self._port = port self._code = code self._encoding = kwargs.get('encoding', 'utf-8') self._max_concurrency = kwargs.get('concurrency', 4) self._communication_delay = kwargs.get('communication_delay', 0) self._reader = None self._writer = None self._crypt = None self._listen_task = None self._keep_alive_task = None self._semaphore = None self._queue = None @property def queue(self): return self._queue async def connect(self): self._cmd_id = 0 try: self._semaphore = asyncio.Semaphore(self._max_concurrency) self._futures = [None for i in range(MIN_CMD_ID, MIN_CMD_ID + MAX_CMD_ID)] self._reader, self._writer = await asyncio.open_connection(self._host, self._port) if self._communication_delay > 0: await asyncio.sleep(self._communication_delay) self._queue = asyncio.Queue() self._listen_task = asyncio.create_task(self._listen()) self._crypt = RiscoCrypt(self._encoding) panel_id = int(await self.send_result_command('RID'), 16) self._crypt.set_panel_id(panel_id) if not await self.send_ack_command('LCL'): raise CannotConnectError command = f'RMT={self._code}' if not await self.send_ack_command(command): raise UnauthorizedError self._keep_alive_task = asyncio.create_task(self._keep_alive()) except Exception as exc: await self._close() raise CannotConnectError from exc async def disconnect(self): if self._writer: try: await self.send_ack_command('DCN') except OperationError: # safe to ignore these when disconnecting pass finally: await self._close() async def _listen(self): while True: try: cmd_id, command, crc = await self._read_command() if not cmd_id: self._decrement_cmd_id() raise OperationError(f'Risco error: {command}') if cmd_id <= MAX_CMD_ID: future = self._futures[cmd_id-1] self._futures[cmd_id-1] = None if not crc: future.set_exception(OperationError(f'cmd_id: {cmd_id}, Wrong CRC')) elif command[0] in ['N', 'B']: future.set_exception(OperationError(f'cmd_id: {cmd_id}, Risco error: {command}')) else: future.set_result(command) else: await self._handle_incoming(cmd_id, command, crc) except Exception as error: await self._queue.put(error) async def _keep_alive(self): while True: try: await self.send_result_command("CLOCK") except OperationError as error: await self._queue.put(error) await asyncio.sleep(5) async def send_ack_command(self, command): command = await self.send_command(command) return command == 'ACK' async def send_result_command(self, command): command = await self.send_command(command) return command.split("=")[1] async def send_result_command_limited(self, command): async with self._semaphore: return await self.send_result_command(command) async def send_command(self, command): self._increment_cmd_id() cmd_id = self._cmd_id future = asyncio.Future() self._futures[cmd_id-1] = future self._write_command(cmd_id, command) try: async with asyncio.timeout(1): return await future except asyncio.TimeoutError: raise OperationError(f'Timeout in command: {command}') async def _handle_incoming(self, cmd_id, command, crc): self._write_command(cmd_id, 'ACK') if not crc: await self._queue.put(OperationError(f'cmd_id: {cmd_id}, Wrong CRC')) else: await self._queue.put(command) async def _read_command(self): buffer = await self._reader.readuntil(END) while buffer.endswith(ESCAPED_END): buffer += await self._reader.readuntil(END) return self._crypt.decode(buffer) def _write_command(self, cmd_id, command): buffer = self._crypt.encode(cmd_id, command) self._writer.write(buffer) async def _close(self): if self._keep_alive_task: self._keep_alive_task.cancel() self._keep_alive_task = None if self._listen_task: self._listen_task.cancel() self._listen_task = None if self._writer: self._writer.close() await self._writer.wait_closed() self._crypt = None self._writer = None self._reader = None self._semaphore = None self._queue = None # Risco needs a few seconds to reset its encryption state before accepting a new connection # If we don't sleep here, the next connection will be encrypted before we get the panel id. await asyncio.sleep(5) def _increment_cmd_id(self): self._cmd_id += 1 if self._cmd_id > MAX_CMD_ID: self._cmd_id = MIN_CMD_ID def _decrement_cmd_id(self): self._cmd_id -= 1 if self._cmd_id < MIN_CMD_ID: self._cmd_id = MAX_CMD_ID pyrisco-0.7.0-rc0/pyrisco/local/system.py000066400000000000000000000021111461247041700203510ustar00rootroot00000000000000from pyrisco.common import System as BaseSystem class System(BaseSystem): def __init__(self, panel, label, status): """Read system from response.""" self._panel = panel self._status = status self._name = label.strip() @property def name(self): """System name.""" return self._name @property def low_battery_trouble(self): return 'B' in self._status @property def ac_trouble(self): return 'A' in self._status @property def monitoring_station_1_trouble(self): return '1' in self._status @property def monitoring_station_2_trouble(self): return '2' in self._status @property def monitoring_station_3_trouble(self): return '3' in self._status @property def phone_line_trouble(self): return 'P' in self._status @property def clock_trouble(self): return 'C' in self._status @property def box_tamper(self): return 'X' in self._status @property def programming_mode(self): return 'I' in self._status def update_status(self, status): self._status = status pyrisco-0.7.0-rc0/pyrisco/local/zone.py000066400000000000000000000031571461247041700200130ustar00rootroot00000000000000from pyrisco.common import GROUP_ID_TO_NAME, Zone as BaseZone class Zone(BaseZone): def __init__(self, panel, zone_id, status, zone_type, label, partitions, groups, tech): self._panel = panel self._id = zone_id self._status = status self._type = zone_type self._name = label.strip() self._partitions = partitions self._groups = int(groups, 16) self._tech = tech async def bypass(self, bypass): """Bypass or unbypass the zone.""" return await self._panel.bypass_zone(self.id, bypass) @property def id(self): """Zone ID number.""" return self._id @property def name(self): """Zone name.""" return self._name @property def type(self): """Zone type.""" return self._type @property def triggered(self): """Is the zone triggered.""" return 'O' in self._status @property def alarmed(self): """Is the zone causing an alarm.""" return 'a' in self._status @property def armed(self): """Is the zone armed.""" return 'A' in self._status @property def bypassed(self): """Is the zone bypassed.""" return 'Y' in self._status @property def groups(self): """Groups the zone belongs to.""" return [GROUP_ID_TO_NAME[i] for i in range(0,4) if ((2**i) & self._groups) > 0] @property def partitions(self): """partitions the zone belongs to.""" ps = zip([int(p, 16) for p in self._partitions], range(0, len(self._partitions))) return [i*4 + p + 1 for c, i in ps for p in range(0,4) if ((2**p) & c) > 0] def update_status(self, status): self._status = status pyrisco-0.7.0-rc0/setup.py000066400000000000000000000064671461247041700154250ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # Note: To use the 'upload' functionality of this file, you must: # $ pipenv install twine --dev import io import os import sys from shutil import rmtree from setuptools import find_packages, setup, Command # Package meta-data. NAME = 'pyrisco' DESCRIPTION = 'A python library to communicate with Risco Cloud.' URL = 'https://github.com/OnFreund/PyRisco' EMAIL = 'onfreund@gmail.com' AUTHOR = 'On Freund' REQUIRES_PYTHON = '>=3.7.0' VERSION = '0.7.0-rc0' REQUIRED = ['aiohttp'] EXTRAS = {} here = os.path.abspath(os.path.dirname(__file__)) # Import the README and use it as the long-description. # Note: this will only work if 'README.md' is present in your MANIFEST.in file! try: with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: long_description = '\n' + f.read() except FileNotFoundError: long_description = DESCRIPTION # Load the package's __version__.py module as a dictionary. about = {} if not VERSION: project_slug = NAME.lower().replace("-", "_").replace(" ", "_") with open(os.path.join(here, project_slug, '__version__.py')) as f: exec(f.read(), about) else: about['__version__'] = VERSION class UploadCommand(Command): """Support setup.py upload.""" description = 'Build and publish the package.' user_options = [] @staticmethod def status(s): """Prints things in bold.""" print('\033[1m{0}\033[0m'.format(s)) def initialize_options(self): pass def finalize_options(self): pass def run(self): try: self.status('Removing previous builds…') rmtree(os.path.join(here, 'dist')) except OSError: pass self.status('Building Source and Wheel distribution…') os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable)) self.status('Uploading the package to PyPI via Twine…') os.system('twine upload dist/*') self.status('Pushing git tags…') os.system('git tag v{0}'.format(about['__version__'])) os.system('git push --tags') sys.exit() # Where the magic happens: setup( name=NAME, version=about['__version__'], description=DESCRIPTION, long_description=long_description, long_description_content_type='text/markdown', author=AUTHOR, author_email=EMAIL, python_requires=REQUIRES_PYTHON, url=URL, packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), # If your package is a single module, use this instead of 'packages': # py_modules=['mypackage'], # entry_points={ # 'console_scripts': ['mycli=mymodule:cli'], # }, install_requires=REQUIRED, extras_require=EXTRAS, include_package_data=True, license='MIT', classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy' ], # $ setup.py publish support. cmdclass={ 'upload': UploadCommand, }, )