Skip to content

Commit

Permalink
Output Control Support for Hills Comnav (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ay1tsMe authored Jul 23, 2023
1 parent d1e7555 commit fd31434
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 3 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
# NX-595E Output Control Fork
This fork is designated to implementing the "Output Control" section of the NX-595E. The main objective is to enable communication with the outputs and ensure its proper implementation.

## How does output control work?
Output Control is handled through the `output.htm` file within the web app. From my understanding, Output Controls has two key value pairs that identify each output:
```
{
'name': "Garage Auto Door",
'state': "0",
}
```
In this example, the `name` is the name of the output and the `state` is whether the output is "on" or "off", 0 being "off" and 1 being "on"

To activate the Output Control switch, a post request is made to `/user/output.cgi` with the following parameters:
```
{
'sess': self.session_id,
'onum': 1,
'ostate': 1
}
```
In this example, the `sess` is referred to the session ID of the current login, `onum` is the index of the output (`'onum': 1` refers to the first output), and `ostate` which refers to the state in which you want to set the output (`'ostate': 1` means on and `'ostate': 0` means off).

# NX-595E UltraSync Hub

Compatible with both NX-595E [Hills](https://www.hills.com.au/) ComNav, xGen, xGen8 (such as [NXG-8-Z-BO](https://firesecurityproducts.com/en/product/intrusion/NXG_8_Z_BO/82651)), [Interlogix](https://www.interlogix.com/), and [ZeroWire](https://www.interlogix.com/intrusion/product/ultrasync-selfcontained-hub) UltraSync solutions.
Expand Down
16 changes: 15 additions & 1 deletion ultrasync/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ def print_version_msg():
@click.option('--zone', type=int, metavar='ZONE',
help='Specify the Zone you wish to target with a --bypass '
'action.')
@click.option('--output', type=int, metavar='OUTPUT',
help='Specify the Output you wish to control with a --switch action.')
@click.option('--switch', type=int,
metavar='STATE',
help='Set to 1 to turn on an output, set to 0 to turn it off.')
@click.option('--full-debug-dump', is_flag=True,
help='Dump a full set of tracing files to a archive for '
'comparison/debug purposes. Usually the --debug-dump is '
Expand All @@ -116,7 +121,7 @@ def print_version_msg():
@click.option('--version', '-V', is_flag=True,
help='Display the version of the ultrasync library and exit.')
def main(config, debug_dump, full_debug_dump, scene, bypass, details, watch,
area, zone, verbose, version):
area, zone, output, switch, verbose, version):
"""
Wrapper to ultrasync library.
"""
Expand Down Expand Up @@ -201,6 +206,15 @@ def main(config, debug_dump, full_debug_dump, scene, bypass, details, watch,
if not usync.set_zone_bypass(zone=zone, state=bypass):
sys.exit(1)
actioned = True

if output is not None and switch is not None:
if switch not in [0,1]:
logger.error('Switch state should be either 0 or 1')
sys.exit(1)
if not usync.set_output_control(output=output, state=switch):
sys.exit(1)
actioned = True


if watch:
area_delta = {}
Expand Down
102 changes: 100 additions & 2 deletions ultrasync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ def __init__(self, *args, **kwargs):
self.areas = {}
self._asequence = None

# Our output controls get populated after we connect
self.outputs = {}

# Track the time our information was polled from our panel
self.__updated = None

Expand Down Expand Up @@ -218,7 +221,7 @@ def login(self):
self.release,
))

if not self._areas(response=response) or not self._zones():
if not self._areas(response=response) or not self._zones() or not self.output_control():
# No match and/or bad login
logger.error('Failed to authenticate to {}'.format(self.host))
return False
Expand Down Expand Up @@ -279,6 +282,11 @@ def debug_dump(self, path=None, mode=0o755, full=False, compress=False,
'path': '/user/zones.htm',
},

# Output Control URL
'outputs.htm': {
'path': '/user/outputs.htm',
},

# Config Main Screen
'config2.htm': {
'path': '/muser/config2.htm',
Expand Down Expand Up @@ -672,7 +680,7 @@ def update(self, ref=None, max_age_sec=1):

def details(self, max_age_sec=1):
"""
Arranges the areas and zones into an easy to manage dictionary
Arranges the areas, zones and outputs into an easy to manage dictionary
"""
if not self.update(max_age_sec=max_age_sec):
return {}
Expand All @@ -685,6 +693,7 @@ def details(self, max_age_sec=1):
},
'zones': [z for z in self.zones.values()],
'areas': [a for a in self.areas.values()],
'outputs': [o for o in self.outputs.values()],
'date': self.__updated.strftime('%Y-%m-%d %H:%M:%S'),
}

Expand Down Expand Up @@ -2091,6 +2100,95 @@ def _comnav_zone_status_update(self, bank=0):

return response

def output_control(self):
"""
Parses the Output Control from the UltraSync panel
"""

if not self.session_id and not self.login():
return False

logger.info('Retrieving initial Output Control information.')

# Perform our Query
response = self.__get('/user/outputs.htm', rtype=HubResponseType.RAW)
if not response:
return False

if self.vendor is NX595EVendor.COMNAV:
# Regex to capture output names and states
name_pattern = re.compile(r'var oname(\d) = decodeURIComponent\(decode_utf8\("([^"]*)"\)\);')
state_pattern = re.compile(r'var ostate(\d) = "(\d)";')

# Extract names and states
names = {int(m.group(1)): unquote(m.group(2)) for m in name_pattern.finditer(response)}
states = {int(m.group(1)): m.group(2) for m in state_pattern.finditer(response)}

# Store our outputs:
for i in range(1, max(len(names), len(states)) + 1):
self.outputs[i] = {
'name': names.get(i, ''),
'state': states.get(i, '0'),
}
# If Vendor is supported, elif statement for vendor goes here:

# Otherwise:
else:
logger.error(
'Output Control not implemented for vendor {}'.format(self.vendor))
return False

return True

def set_output_control(self, output, state):
"""
Sets output control on/off
"""
if not self.session_id and not self.login():
return False

if not isinstance(output, int) or output not in self.outputs.keys():
logger.error(
'{} is not valid output'.format(output))
return False

# A boolean for tracking any errors
has_error = False

# Start our payload off with our session identifier
payload = {
'sess': self.session_id,
}

if self.vendor is NX595EVendor.COMNAV:
# Update payload with variables
payload.update({
'onum': output,
'ostate': state
})

# Send our response
response = self.__get(
'/user/output.cgi', payload=payload)
# If Vendor is supported, elif statement for vendor goes here:

# Otherwise:
else:
logger.error(
'Output Control not implemented for vendor {}'.format(self.vendor))
return False

if not response:
logger.info(
'Failed to set state={} for output {}'.format(state, output))
has_error = True

logger.info(
'Set state={} for output {} successfully'.format(state, output))

return not has_error

def _sequence(self):
"""
Returns the sequences for both the zones, entries, and areas
Expand Down

0 comments on commit fd31434

Please sign in to comment.