diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ccfe229 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,82 @@ +name: Tests + +on: + + # On which repository actions to trigger the build. + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allow job to be triggered manually. + workflow_dispatch: + +# Cancel in-progress jobs when pushing to the same branch. +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + + tests: + + runs-on: ${{ matrix.os }} + strategy: + + # Run all jobs to completion (false), or cancel + # all jobs once the first one fails (true). + fail-fast: true + + # Define a minimal test matrix, it will be + # expanded using subsequent `include` items. + matrix: + os: ["ubuntu-latest"] + python-version: ["3.10"] + bare: [false] + + defaults: + run: + shell: bash + + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + BARE: ${{ matrix.bare }} + + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} ${{ matrix.bare && '(bare)' || '' }} + steps: + + - name: Acquire sources + uses: actions/checkout@v3 + + - name: Install prerequisites (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + + - name: Install project dependencies (Baseline) + run: | + pip install -r requirements.txt -r dev-requirements.txt + + # For saving resources, code style checking is + # only invoked within the `bare` environment. + - name: Check code style + if: matrix.bare == true + run: | + flake8 ultrasync --count --show-source --statistics + + - name: Run tests + run: | + coverage run -m pytest + + - name: Process coverage data + run: | + coverage xml + coverage report + + - name: Upload coverage data + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index def5706..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: python - -dist: focal - -matrix: - include: - - python: "3.8" - env: TOXENV=py38 - - python: "3.9" - env: TOXENV=py39 - - python: "3.10" - env: TOXENV=py310 - -install: - - pip install . - - pip install codecov - - pip install -r dev-requirements.txt - - pip install -r requirements.txt - - -# run tests -script: - - tox - -after_success: - - tox -e coverage-report - - codecov - -notifications: - email: false diff --git a/README.md b/README.md index 60abf55..296ebcf 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,6 @@ # 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. @@ -30,7 +10,7 @@ Compatible with both NX-595E [Hills](https://www.hills.com.au/) ComNav, xGen, xG [![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://paypal.me/lead2gold?locale.x=en_US) [![Follow](https://img.shields.io/twitter/follow/l2gnux)](https://twitter.com/l2gnux/)
[![Python](https://img.shields.io/pypi/pyversions/ultrasync.svg?style=flat-square)](https://pypi.org/project/ultrasync/) -[![Build Status](https://travis-ci.org/caronc/ultrasync.svg?branch=master)](https://travis-ci.org/caronc/ultrasync) +[![Build Status](https://github.com/caronc/ultrasync/actions/workflows/tests.yml/badge.svg)](https://github.com/caronc/ultrasync/actions/workflows/tests.yml) [![CodeCov Status](https://codecov.io/github/caronc/ultrasync/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/ultrasync) [![Downloads](http://pepy.tech/badge/ultrasync)](https://pypi.org/project/ultrasync/) diff --git a/bin/checkdone.sh b/bin/checkdone.sh index 56a5f19..e85be2a 100755 --- a/bin/checkdone.sh +++ b/bin/checkdone.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # Copyright (C) 2020 Chris Caron # All rights reserved. # diff --git a/bin/test.sh b/bin/test.sh index dab3da6..9ee5a27 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # Copyright (C) 2020 Chris Caron # All rights reserved. # diff --git a/setup.cfg b/setup.cfg index 383cc9c..5aef9cd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,4 +20,3 @@ addopts = --verbose -ra python_files = tests/test_*.py filterwarnings = once::Warning -strict = true diff --git a/setup.py b/setup.py index 30f7258..3020d3b 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ setup( name='ultrasync', - version='0.9.8', + version='0.9.9', description='Wrapper to XGen/XGen8/Hills/Interlogix NX-595E/UltraSync ' 'ZeroWire', license='MIT', diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a57ecc..65df72d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock from importlib import reload import requests from os.path import join diff --git a/tests/test_comnav_0_106.py b/tests/test_comnav_0_106.py index 813a3f4..af5dd12 100644 --- a/tests/test_comnav_0_106.py +++ b/tests/test_comnav_0_106.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname @@ -87,8 +87,15 @@ def test_comnav_0_106_communication(mock_post): ast_obj.content = f.read() ast_obj.status_code = requests.codes.ok + # A Control response object (for garage door handling) + # At the time, i did not have a sample to work with, so we will use empty + # data for now to satisfy tests + crobj = mock.Mock() + crobj.status_code = requests.codes.ok + crobj.content = b'' + # Assign our response object to our mocked instance of requests - mock_post.side_effect = (arobj, zrobj) + mock_post.side_effect = (arobj, zrobj, crobj) uobj = UltraSync() @@ -139,11 +146,13 @@ def test_comnav_0_106_communication(mock_post): assert uobj.zones[entry['bank']]['can_bypass'] is True # A call to login.cgi (which fetches area.html) and then zones.htm - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 assert mock_post.call_args_list[0][0][0] == \ 'http://zerowire/login.cgi' assert mock_post.call_args_list[1][0][0] == \ 'http://zerowire/user/zones.htm' + assert mock_post.call_args_list[2][0][0] == \ + 'http://zerowire/user/outputs.htm' # Reset our mock object mock_post.reset_mock() diff --git a/tests/test_comnav_0_108-zone-check.py b/tests/test_comnav_0_108-zone-check.py index 0a22776..4a01092 100644 --- a/tests/test_comnav_0_108-zone-check.py +++ b/tests/test_comnav_0_108-zone-check.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname @@ -87,8 +87,15 @@ def test_comnav_0_108_zone_filter(mock_post): ast_obj.content = f.read() ast_obj.status_code = requests.codes.ok + # A Control response object (for garage door handling) + # At the time, i did not have a sample to work with, so we will use empty + # data for now to satisfy tests + crobj = mock.Mock() + crobj.status_code = requests.codes.ok + crobj.content = b'' + # Assign our response object to our mocked instance of requests - mock_post.side_effect = (arobj, zrobj) + mock_post.side_effect = (arobj, zrobj, crobj) uobj = UltraSync() @@ -118,11 +125,13 @@ def test_comnav_0_108_zone_filter(mock_post): assert len(uobj.zones) == 17 # A call to login.cgi (which fetches area.html) and then zones.htm - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 assert mock_post.call_args_list[0][0][0] == \ 'http://zerowire/login.cgi' assert mock_post.call_args_list[1][0][0] == \ 'http://zerowire/user/zones.htm' + assert mock_post.call_args_list[2][0][0] == \ + 'http://zerowire/user/outputs.htm' # Reset our mock object mock_post.reset_mock() diff --git a/tests/test_comnav_0_108.py b/tests/test_comnav_0_108.py index 1f7ddd8..71cb1cd 100644 --- a/tests/test_comnav_0_108.py +++ b/tests/test_comnav_0_108.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname @@ -87,8 +87,15 @@ def test_comnav_0_108_communication(mock_post): ast_obj.content = f.read() ast_obj.status_code = requests.codes.ok + # A Control response object (for garage door handling) + # At the time, i did not have a sample to work with, so we will use empty + # data for now to satisfy tests + crobj = mock.Mock() + crobj.status_code = requests.codes.ok + crobj.content = b'' + # Assign our response object to our mocked instance of requests - mock_post.side_effect = (arobj, zrobj) + mock_post.side_effect = (arobj, zrobj, crobj) uobj = UltraSync() @@ -138,11 +145,13 @@ def test_comnav_0_108_communication(mock_post): assert uobj.zones[entry['bank']]['can_bypass'] is True # A call to login.cgi (which fetches area.html) and then zones.htm - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 assert mock_post.call_args_list[0][0][0] == \ 'http://zerowire/login.cgi' assert mock_post.call_args_list[1][0][0] == \ 'http://zerowire/user/zones.htm' + assert mock_post.call_args_list[2][0][0] == \ + 'http://zerowire/user/outputs.htm' # Reset our mock object mock_post.reset_mock() diff --git a/tests/test_comnav_0_108_burglar_alarm_on.py b/tests/test_comnav_0_108_burglar_alarm_on.py index 096c05a..316893f 100644 --- a/tests/test_comnav_0_108_burglar_alarm_on.py +++ b/tests/test_comnav_0_108_burglar_alarm_on.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname @@ -87,8 +87,15 @@ def test_comnav_0_108_active_burglary(mock_post): ast_obj.content = f.read() ast_obj.status_code = requests.codes.ok + # A Control response object (for garage door handling) + # At the time, i did not have a sample to work with, so we will use empty + # data for now to satisfy tests + crobj = mock.Mock() + crobj.status_code = requests.codes.ok + crobj.content = b'' + # Assign our response object to our mocked instance of requests - mock_post.side_effect = (arobj, zrobj) + mock_post.side_effect = (arobj, zrobj, crobj) uobj = UltraSync() @@ -138,8 +145,10 @@ def test_comnav_0_108_active_burglary(mock_post): assert uobj.zones[entry['bank']]['can_bypass'] is True # A call to login.cgi (which fetches area.html) and then zones.htm - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 assert mock_post.call_args_list[0][0][0] == \ 'http://zerowire/login.cgi' assert mock_post.call_args_list[1][0][0] == \ 'http://zerowire/user/zones.htm' + assert mock_post.call_args_list[2][0][0] == \ + 'http://zerowire/user/outputs.htm' diff --git a/tests/test_xgen8_bypass.py b/tests/test_xgen8_bypass.py index 4770836..4c66911 100644 --- a/tests/test_xgen8_bypass.py +++ b/tests/test_xgen8_bypass.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname diff --git a/tests/test_xgen8_nxg8zbo.py b/tests/test_xgen8_nxg8zbo.py index d4142a4..bdf2f47 100644 --- a/tests/test_xgen8_nxg8zbo.py +++ b/tests/test_xgen8_nxg8zbo.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname diff --git a/tests/test_xgen_general.py b/tests/test_xgen_general.py index 4f10ef9..1a83c0a 100644 --- a/tests/test_xgen_general.py +++ b/tests/test_xgen_general.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname diff --git a/tests/test_zerowire_armed.py b/tests/test_zerowire_armed.py index cd25e37..e70fe41 100644 --- a/tests/test_zerowire_armed.py +++ b/tests/test_zerowire_armed.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname diff --git a/tests/test_zerowire_general.py b/tests/test_zerowire_general.py index 532a85b..3e9202d 100644 --- a/tests/test_zerowire_general.py +++ b/tests/test_zerowire_general.py @@ -23,7 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import mock +from unittest import mock import requests from os.path import join from os.path import dirname diff --git a/tox.ini b/tox.ini index 1d1f60f..ef077f5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] -envlist = py38,py39,py310,coverage-report - +envlist = py311,coverage-report +skipsdist = true [testenv] # Prevent random setuptools/pip breakages like @@ -11,36 +11,20 @@ deps= -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt commands = - coverage run --parallel -m pytest {posargs} - flake8 . --count --show-source --statistics - -[testenv:py38] -deps= - -r{toxinidir}/requirements.txt - -r{toxinidir}/dev-requirements.txt -commands = - coverage run --parallel -m pytest {posargs} - flake8 . --count --show-source --statistics - -[testenv:py39] -deps= - -r{toxinidir}/requirements.txt - -r{toxinidir}/dev-requirements.txt -commands = - coverage run --parallel -m pytest {posargs} - flake8 . --count --show-source --statistics + coverage run --parallel -m pytest {posargs} ultrasync + flake8 ultrasync --count --show-source --statistics -[testenv:py310] +[testenv:py311] deps= -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt commands = - coverage run --parallel -m pytest {posargs} - flake8 . --count --show-source --statistics + coverage run --parallel -m pytest {posargs} ultrasync + flake8 ultrasync --count --show-source --statistics [testenv:coverage-report] deps = coverage skip_install = true commands= - coverage combine - coverage report + coverage combine ultrasync + coverage report ultrasync diff --git a/ultrasync/__init__.py b/ultrasync/__init__.py index d80531f..8cd3ede 100644 --- a/ultrasync/__init__.py +++ b/ultrasync/__init__.py @@ -24,7 +24,7 @@ # THE SOFTWARE. __title__ = 'ultrasync' -__version__ = '0.9.8' +__version__ = '0.9.9' __author__ = 'Chris Caron' __license__ = 'MIT' __copywrite__ = 'Copyright (C) 2023 Chris Caron ' diff --git a/ultrasync/cli.py b/ultrasync/cli.py index 1659957..dc59034 100644 --- a/ultrasync/cli.py +++ b/ultrasync/cli.py @@ -106,7 +106,8 @@ def print_version_msg(): 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.') + 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.') @@ -206,16 +207,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]: + 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 = {} zone_delta = {} diff --git a/ultrasync/main.py b/ultrasync/main.py index cdc6894..b3f7846 100644 --- a/ultrasync/main.py +++ b/ultrasync/main.py @@ -221,11 +221,14 @@ def login(self): self.release, )) - if not self._areas(response=response) or not self._zones() or not self.output_control(): + if not self._areas(response=response) or not self._zones(): # No match and/or bad login logger.error('Failed to authenticate to {}'.format(self.host)) return False + # Prepare output control (if supported) + self.output_control() + # Update our time reference self.__updated = datetime.now() @@ -602,15 +605,15 @@ def set_zone_bypass(self, zone, state=False): } if self.vendor in (NX595EVendor.ZEROWIRE, NX595EVendor.XGEN8): - payload.update({ + payload.update({ 'cmd': 5, 'opt': int(state), 'zone': zone - 1, }) - - # Send our response - response = self.__get( - '/user/zonefunction.cgi', payload=payload) + + # Send our response + response = self.__get( + '/user/zonefunction.cgi', payload=payload) elif self.vendor in (NX595EVendor.COMNAV): # Call comnav_process_zones to update can_bypass attribute @@ -619,8 +622,8 @@ def set_zone_bypass(self, zone, state=False): # Get the current can_bypass state of the zone can_bypass = self.zones[zone - 1]['can_bypass'] - # If the current can_bypass state does not match the desired bypass state, - # toggle the bypass state + # If the current can_bypass state does not match the desired + # bypass state, toggle the bypass state if can_bypass == state: # Start our payload off with our session identifier payload = { @@ -628,20 +631,19 @@ def set_zone_bypass(self, zone, state=False): 'comm': 82, 'data0': zone - 1, } - else: + else: payload = {} # Send our response response = self.__get( '/user/zonefunction.cgi', payload=payload) - + else: # self.vendor is NX595EVendor.{ZEROWIRE, XGEN} logger.error( 'Bypass not implemented for vendor {}'.format(self.vendor)) return False - if not response: logger.info( 'Failed to set bypass={} for zone {}'.format(state, zone)) @@ -2103,11 +2105,18 @@ def _comnav_zone_status_update(self, bank=0): def output_control(self): """ Parses the Output Control from the UltraSync panel + + At this time, this feature is only supported by the COMNAV panels """ + if self.vendor is not NX595EVendor.COMNAV: + # Only vendor that supports this right now is COMNAV + # Silently returned a positive status + return True + if not self.session_id and not self.login(): return False - + logger.info('Retrieving initial Output Control information.') # Perform our Query @@ -2115,14 +2124,17 @@ def output_control(self): 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\("([^"]*)"\)\);') + name_pattern = re.compile( + r'var oname(\d) = decodeURIComponent' + r'\(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)} + 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): @@ -2130,21 +2142,23 @@ def output_control(self): '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 + At this time, this feature is only supported by the COMNAV panels """ + if self.vendor is not NX595EVendor.COMNAV: + # Only vendor that supports this right now is COMNAV + logger.error( + 'Output control not implemented for vendor {}'.format( + self.vendor)) + return False + if not self.session_id and not self.login(): return False @@ -2152,7 +2166,7 @@ def set_output_control(self, output, state): logger.error( '{} is not valid output'.format(output)) return False - + # A boolean for tracking any errors has_error = False @@ -2161,24 +2175,14 @@ def set_output_control(self, output, state): '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 + # Update payload with variables + payload.update({ + 'onum': output, + 'ostate': state + }) + # Send our response + response = self.__get('/user/output.cgi', payload=payload) if not response: logger.info( 'Failed to set state={} for output {}'.format(state, output)) @@ -2186,7 +2190,7 @@ def set_output_control(self, output, state): logger.info( 'Set state={} for output {} successfully'.format(state, output)) - + return not has_error def _sequence(self):