Repository: marcaurele/gce-ipx800

Issue:
Implement Virtual outputs features in this package
"ipx.relays" is working fine.  My project is using Virtual outputs, can this be  added to the wishlist?
Thank you 
Inz

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=h1) Report
> Merging [#11](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=desc) (3adecd1) into [main](https://codecov.io/gh/marcaurele/gce-ipx800/commit/fa71389b2525e2956dfe5a36f2d6aa379e7f7dd2?el=desc) (fa71389) will **decrease** coverage by `1.55%`.
> The diff coverage is `90.47%`.

[![Impacted file tree graph](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11/graphs/tree.svg?width=650&height=150&src=pr&token=he9BkpfbR2)](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##             main      #11      +/-   ##
==========================================
- Coverage   97.97%   96.42%   -1.56%     
==========================================
  Files           2        2              
  Lines          99      112      +13     
==========================================
+ Hits           97      108      +11     
- Misses          2        4       +2     
```


| [Impacted Files](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [ipx800/ipx800.py](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11/diff?src=pr&el=tree#diff-aXB4ODAwL2lweDgwMC5weQ==) | `96.33% <90.47%> (-1.59%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=footer). Last update [fa71389...3adecd1](https://codecov.io/gh/marcaurele/gce-ipx800/pull/11?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.rst b/README.rst
index 537c1cc..585f7db 100644
--- a/README.rst
+++ b/README.rst
@@ -29,7 +29,13 @@ A python library to control a GCE-Electronics IPX800 V4 device through its API.
 IPX800 features implemented
 ---------------------------
 
-* Control relays
+* Control:
+
+  - relays (``ipx.relays[]``),
+  - virtual inputs (``ipx.virtual_inputs[]``),
+  - virtual outputs (``ipx.virtual_outputs[]``).
+
+* Read values from analog sensors (``ipx.analogs[]``).
 
 
 Installation
diff --git a/ipx800/ipx800.py b/ipx800/ipx800.py
index 56970bf..8b6a544 100644
--- a/ipx800/ipx800.py
+++ b/ipx800/ipx800.py
@@ -14,8 +14,12 @@ class ApiError(Exception):
 class IPX800:
     """Class representing the IPX800 and its API.
 
-       Attributes:
-           relays  the IPX800 relays"""
+    Attributes:
+        analogs  the analog sensors
+        relays  the physical relays
+        virtual_inputs  the virtual inputs
+        virtual_outputs  the virtual outputs
+    """
 
     def __init__(self, url, api_key="apikey"):
         self.url = url
@@ -23,6 +27,8 @@ class IPX800:
         self.api_key = api_key
         self.relays = GenericSlice(self, Relay, {"Get": "R"})
         self.analogs = GenericSlice(self, Analog, {"Get": "A"})
+        self.virtual_inputs = GenericSlice(self, VirtualInput, {"Get": "VI"})
+        self.virtual_outputs = GenericSlice(self, VirtualOutput, {"Get": "VO"})
 
     def _request(self, params):
         # (bug) IPX4, key must be the first parameter otherwise some
@@ -72,48 +78,71 @@ class GenericSlice(collections.abc.Sequence):
         return self._length
 
 
-class Relay(IPX800):
-    """Representing an IPX800 relay."""
+class BaseSwitch(IPX800):
+    """Base class to abstract switch operations."""
 
-    def __init__(self, ipx, relay_id: int):
+    def __init__(self, ipx, id: int, name: str, code: str):
         super().__init__(ipx.url, ipx.api_key)
-        self.id = relay_id
+        self.id = id
+        self._name = name
+        self._code = code
 
     @property
     def status(self) -> bool:
-        """Return the current relay status."""
-        params = {"Get": "R"}
+        """Return the current status."""
+        params = {"Get": self._code}
         response = self._request(params)
-        return response[f"R{self.id}"] == 1
+        return response[f"{self._code}{self.id}"] == 1
 
     def on(self) -> bool:
-        """Turn on a relay and return True if it was successful."""
-        params = {"SetR": self.id}
+        """Turn on and return True if it was successful."""
+        params = {f"Set{self._code}": self.id}
         self._request(params)
         return True
 
     def off(self) -> bool:
-        """Turn off a relay and return True if it was successful."""
-        params = {"ClearR": self.id}
+        """Turn off and return True if it was successful."""
+        params = {"Clear{self._code}": self.id}
         self._request(params)
         return True
 
     def toggle(self) -> bool:
-        """Toggle a relay and return True if it was successful."""
-        params = {"ToggleR": self.id}
+        """Toggle and return True if it was successful."""
+        params = {"Toggle{self._code}": self.id}
         self._request(params)
         return True
 
     def __repr__(self) -> str:
-        return f"<ipx800.relay id={self.id}>"
+        return f"<ipx800.{self._name} id={self.id}>"
 
     def __str__(self) -> str:
         return (
-            f"[IPX800-relay: id={self.id}, "
+            f"[IPX800-{self._name}: id={self.id}, "
             f"status={'On' if self.status else 'Off'}]"
         )
 
 
+class Relay(BaseSwitch):
+    """Representing an IPX800 relay."""
+
+    def __init__(self, ipx, id: int):
+        super().__init__(ipx, id, name="relay", code="R")
+
+
+class VirtualInput(BaseSwitch):
+    """Representing an IPX800 virtual input."""
+
+    def __init__(self, ipx, id: int):
+        super().__init__(ipx, id, name="virtual-input", code="VI")
+
+
+class VirtualOutput(BaseSwitch):
+    """Reprenting an IPX800 virtual output."""
+
+    def __init__(self, ipx, id: int):
+        super().__init__(ipx, id, name="virtual-output", code="VO")
+
+
 class Analog(IPX800):
     """Representing an IPX800 analog sensor."""
 


Test patch:
diff --git a/tests/getvi.json b/tests/getvi.json
new file mode 100644
index 0000000..2024565
--- /dev/null
+++ b/tests/getvi.json
@@ -0,0 +1,132 @@
+{
+    "product": "IPX800_V4",
+    "status": "Success",
+    "VI1": 0,
+    "VI2": 0,
+    "VI3": 0,
+    "VI4": 0,
+    "VI5": 0,
+    "VI6": 0,
+    "VI7": 0,
+    "VI8": 0,
+    "VI9": 0,
+    "VI10": 0,
+    "VI11": 0,
+    "VI12": 0,
+    "VI13": 1,
+    "VI14": 0,
+    "VI15": 0,
+    "VI16": 0,
+    "VI17": 0,
+    "VI18": 0,
+    "VI19": 0,
+    "VI20": 1,
+    "VI21": 0,
+    "VI22": 0,
+    "VI23": 0,
+    "VI24": 0,
+    "VI25": 0,
+    "VI26": 0,
+    "VI27": 0,
+    "VI28": 0,
+    "VI29": 0,
+    "VI30": 0,
+    "VI31": 0,
+    "VI32": 0,
+    "VI33": 0,
+    "VI34": 0,
+    "VI35": 0,
+    "VI36": 0,
+    "VI37": 0,
+    "VI38": 0,
+    "VI39": 0,
+    "VI40": 0,
+    "VI41": 0,
+    "VI42": 0,
+    "VI43": 0,
+    "VI44": 0,
+    "VI45": 0,
+    "VI46": 0,
+    "VI47": 0,
+    "VI48": 0,
+    "VI49": 0,
+    "VI50": 0,
+    "VI51": 0,
+    "VI52": 0,
+    "VI53": 0,
+    "VI54": 0,
+    "VI55": 0,
+    "VI56": 0,
+    "VI57": 0,
+    "VI58": 0,
+    "VI59": 0,
+    "VI60": 0,
+    "VI61": 0,
+    "VI62": 0,
+    "VI63": 0,
+    "VI64": 0,
+    "VI65": 0,
+    "VI66": 0,
+    "VI67": 0,
+    "VI68": 0,
+    "VI69": 0,
+    "VI70": 0,
+    "VI71": 0,
+    "VI72": 0,
+    "VI73": 0,
+    "VI74": 0,
+    "VI75": 0,
+    "VI76": 0,
+    "VI77": 0,
+    "VI78": 0,
+    "VI79": 0,
+    "VI80": 0,
+    "VI81": 0,
+    "VI82": 0,
+    "VI83": 0,
+    "VI84": 0,
+    "VI85": 0,
+    "VI86": 0,
+    "VI87": 0,
+    "VI88": 0,
+    "VI89": 0,
+    "VI90": 0,
+    "VI91": 0,
+    "VI92": 0,
+    "VI93": 0,
+    "VI94": 0,
+    "VI95": 0,
+    "VI96": 0,
+    "VI97": 0,
+    "VI98": 0,
+    "VI99": 0,
+    "VI100": 0,
+    "VI101": 0,
+    "VI102": 0,
+    "VI103": 0,
+    "VI104": 0,
+    "VI105": 0,
+    "VI106": 0,
+    "VI107": 0,
+    "VI108": 0,
+    "VI109": 0,
+    "VI110": 0,
+    "VI111": 0,
+    "VI112": 0,
+    "VI113": 0,
+    "VI114": 0,
+    "VI115": 0,
+    "VI116": 0,
+    "VI117": 0,
+    "VI118": 0,
+    "VI119": 0,
+    "VI120": 0,
+    "VI121": 0,
+    "VI122": 0,
+    "VI123": 0,
+    "VI124": 0,
+    "VI125": 0,
+    "VI126": 0,
+    "VI127": 0,
+    "VI128": 0
+}
diff --git a/tests/getvo.json b/tests/getvo.json
new file mode 100644
index 0000000..92b6a6f
--- /dev/null
+++ b/tests/getvo.json
@@ -0,0 +1,132 @@
+{
+    "product": "IPX800_V4",
+    "status": "Success",
+    "VO1": 1,
+    "VO2": 0,
+    "VO3": 0,
+    "VO4": 0,
+    "VO5": 0,
+    "VO6": 0,
+    "VO7": 0,
+    "VO8": 0,
+    "VO9": 0,
+    "VO10": 0,
+    "VO11": 0,
+    "VO12": 0,
+    "VO13": 0,
+    "VO14": 0,
+    "VO15": 0,
+    "VO16": 0,
+    "VO17": 0,
+    "VO18": 0,
+    "VO19": 0,
+    "VO20": 0,
+    "VO21": 0,
+    "VO22": 0,
+    "VO23": 0,
+    "VO24": 0,
+    "VO25": 0,
+    "VO26": 0,
+    "VO27": 0,
+    "VO28": 0,
+    "VO29": 0,
+    "VO30": 0,
+    "VO31": 0,
+    "VO32": 0,
+    "VO33": 0,
+    "VO34": 0,
+    "VO35": 0,
+    "VO36": 0,
+    "VO37": 0,
+    "VO38": 0,
+    "VO39": 0,
+    "VO40": 0,
+    "VO41": 0,
+    "VO42": 0,
+    "VO43": 0,
+    "VO44": 0,
+    "VO45": 0,
+    "VO46": 0,
+    "VO47": 0,
+    "VO48": 0,
+    "VO49": 0,
+    "VO50": 0,
+    "VO51": 0,
+    "VO52": 0,
+    "VO53": 0,
+    "VO54": 0,
+    "VO55": 0,
+    "VO56": 0,
+    "VO57": 0,
+    "VO58": 0,
+    "VO59": 0,
+    "VO60": 0,
+    "VO61": 0,
+    "VO62": 0,
+    "VO63": 0,
+    "VO64": 0,
+    "VO65": 0,
+    "VO66": 0,
+    "VO67": 0,
+    "VO68": 0,
+    "VO69": 0,
+    "VO70": 0,
+    "VO71": 0,
+    "VO72": 0,
+    "VO73": 0,
+    "VO74": 0,
+    "VO75": 0,
+    "VO76": 0,
+    "VO77": 0,
+    "VO78": 0,
+    "VO79": 0,
+    "VO80": 0,
+    "VO81": 0,
+    "VO82": 0,
+    "VO83": 0,
+    "VO84": 0,
+    "VO85": 0,
+    "VO86": 0,
+    "VO87": 0,
+    "VO88": 0,
+    "VO89": 0,
+    "VO90": 0,
+    "VO91": 0,
+    "VO92": 0,
+    "VO93": 0,
+    "VO94": 0,
+    "VO95": 0,
+    "VO96": 0,
+    "VO97": 0,
+    "VO98": 0,
+    "VO99": 0,
+    "VO100": 0,
+    "VO101": 0,
+    "VO102": 0,
+    "VO103": 0,
+    "VO104": 0,
+    "VO105": 0,
+    "VO106": 0,
+    "VO107": 0,
+    "VO108": 0,
+    "VO109": 0,
+    "VO110": 0,
+    "VO111": 0,
+    "VO112": 0,
+    "VO113": 0,
+    "VO114": 0,
+    "VO115": 0,
+    "VO116": 0,
+    "VO117": 0,
+    "VO118": 0,
+    "VO119": 0,
+    "VO120": 0,
+    "VO121": 0,
+    "VO122": 0,
+    "VO123": 0,
+    "VO124": 0,
+    "VO125": 0,
+    "VO126": 0,
+    "VO127": 0,
+    "VO128": 1
+}
diff --git a/tests/test_ipx800.py b/tests/test_ipx800.py
index cbebcde..6df7755 100644
--- a/tests/test_ipx800.py
+++ b/tests/test_ipx800.py
@@ -135,6 +135,77 @@ class IPX800Test(TestCase):
         with self.assertRaises(ApiError):
             ipx.relays[3].on()
 
+    @patch("requests.get")
+    def test_virtuals_length(self, mock_request):
+        mock_request.side_effect = [
+            self._mock_response(json_file="tests/getvi.json"),
+            self._mock_response(json_file="tests/getvo.json"),
+        ]
+
+        ipx = ipx800("http://192.0.2.4")
+        self.assertEqual(len(ipx.virtual_inputs), 128)
+        self.assertEqual(len(ipx.virtual_outputs), 128)
+
+    @patch("requests.get")
+    def test_virtuals_iteration(self, mock_request):
+        mock_request.side_effect = [
+            self._mock_response(json_file="tests/getvi.json")
+            for i in range(128)
+        ]
+        ipx = ipx800("http://192.0.2.4")
+        self.assertEqual(len([r for r in ipx.virtual_inputs]), 128)
+
+    @patch("requests.get")
+    def test_virtuals_status(self, mock_request):
+        mock_request.side_effect = [
+            self._mock_response(json_file="tests/getvi.json"),
+            self._mock_response(json_file="tests/getvi.json"),
+            self._mock_response(json_file="tests/getvo.json"),
+            self._mock_response(json_file="tests/getvo.json"),
+            self._mock_response(json_file="tests/getvi.json"),
+        ]
+
+        ipx = ipx800("http://192.0.2.4")
+        assert ipx.virtual_inputs[12].status
+        assert ipx.virtual_outputs[127].status
+        assert ipx.virtual_inputs[1].status is False
+
+    @patch("requests.get")
+    def test_virtual_input_str(self, mock_request):
+        mock_request.side_effect = [
+            self._mock_response(json_file="tests/getvi.json"),
+            self._mock_response(json_file="tests/getvi.json"),
+            self._mock_response(json_file="tests/getvi.json"),
+        ]
+
+        ipx = ipx800("http://192.0.2.4")
+        self.assertEqual(
+            str(ipx.virtual_inputs[0]),
+            "[IPX800-virtual-input: id=1, status=Off]",
+        )
+        self.assertEqual(
+            str(ipx.virtual_inputs[12]),
+            "[IPX800-virtual-input: id=13, status=On]",
+        )
+
+    @patch("requests.get")
+    def test_virtual_output_str(self, mock_request):
+        mock_request.side_effect = [
+            self._mock_response(json_file="tests/getvo.json"),
+            self._mock_response(json_file="tests/getvo.json"),
+            self._mock_response(json_file="tests/getvo.json"),
+        ]
+
+        ipx = ipx800("http://192.0.2.4")
+        self.assertEqual(
+            str(ipx.virtual_outputs[0]),
+            "[IPX800-virtual-output: id=1, status=On]",
+        )
+        self.assertEqual(
+            str(ipx.virtual_outputs[2]),
+            "[IPX800-virtual-output: id=3, status=Off]",
+        )
+
     @patch("requests.get")
     def test_analog_sensors_length(self, mock_request):
         mock_request.return_value = self._mock_response(