Repository: rm-hull/luma.core

Issue:
OSError: [Errno 5] Input/output error
Was getting this traceback when there wasn't any I2C device connected to the RPi:

```
$ python examples/3d_box.py -i i2c
...
3d_box.py: error: [Errno 5] Input/output error
Error in atexit._run_exitfuncs:
Traceback (most recent call last):
  File "/home/pi/projects/pi-test/luma.core/luma/core/device.py", line 70, in cleanup
    self.hide()
  File "/home/pi/projects/pi-test/luma.core/luma/core/device.py", line 53, in hide
    self.command(self._const.DISPLAYOFF)
  File "/home/pi/projects/pi-test/luma.core/luma/core/device.py", line 32, in command
    self._serial_interface.command(*cmd)
  File "/home/pi/projects/pi-test/luma.core/luma/core/serial.py", line 63, in command
    self._bus.write_i2c_block_data(self._addr, self._cmd_mode, list(cmd))
  File "/home/pi/.virtualenvs/pi-test/lib/python3.4/site-packages/smbus2/smbus2.py", line 274, in write_i2c_block_data
    ioctl(self.fd, I2C_SMBUS, msg)
OSError: [Errno 5] Input/output error
```

I thought this `OSError` business was already fixed before; it should throw a devicenotfound error..

Discussion:
thijstriemstra: Want to add tests before merging it.

Patch:
diff --git a/luma/core/lib.py b/luma/core/lib.py
new file mode 100644
index 0000000..21a12cb
--- /dev/null
+++ b/luma/core/lib.py
@@ -0,0 +1,42 @@
+from functools import wraps
+
+import luma.core.error
+
+
+__all__ = ["rpi_gpio", "spidev"]
+
+
+def __spidev__(self):
+    # spidev cant compile on macOS, so use a similar technique to
+    # initialize (mainly so the tests run unhindered)
+    import spidev
+    return spidev.SpiDev()
+
+
+def __rpi_gpio__(self):
+    # RPi.GPIO _really_ doesn't like being run on anything other than
+    # a Raspberry Pi... this is imported here so we can swap out the
+    # implementation for a mock
+    try:
+        import RPi.GPIO
+        return RPi.GPIO
+    except RuntimeError as e:
+        if str(e) == 'This module can only be run on a Raspberry Pi!':
+            raise luma.core.error.UnsupportedPlatform(
+                'GPIO access not available')
+
+
+def rpi_gpio(f):
+    @wraps(f)
+    def wrapper(*args, **kwds):
+        f.__rpi_gpio__ = classmethod(__rpi_gpio__)
+        return f(*args, **kwds)
+    return wrapper
+
+
+def spidev(f):
+    @wraps(f)
+    def wrapper(*args, **kwds):
+        f.__spidev__ = classmethod(__spidev__)
+        return f(*args, **kwds)
+    return wrapper
diff --git a/luma/core/serial.py b/luma/core/serial.py
index e3b083b..da36935 100644
--- a/luma/core/serial.py
+++ b/luma/core/serial.py
@@ -11,6 +11,8 @@ import errno
 
 import luma.core.error
 
+from luma.core import lib
+
 
 __all__ = ["i2c", "spi"]
 
@@ -21,7 +23,7 @@ class i2c(object):
     Circuit) interface to provide :py:func:`data` and :py:func:`command` methods.
 
     :param bus: a *smbus* implementation, if `None` is supplied (default),
-        `smbus2 <https://https://pypi.python.org/pypi/smbus2/0.1.4>`_ is used.
+        `smbus2 <https://pypi.python.org/pypi/smbus2>`_ is used.
         Typically this is overridden in tests, or if there is a specific
         reason why `pysmbus <https://pypi.python.org/pypi/pysmbus>`_ must be used
         over smbus2
@@ -59,7 +61,7 @@ class i2c(object):
                 # FileNotFoundError
                 raise luma.core.error.DeviceNotFoundError(
                     'I2C device not found: {}'.format(e.filename))
-            elif e.errno == errno.EPERM or e.errno == errno.EACCES:
+            elif e.errno in [errno.EPERM, errno.EACCES]:
                 # PermissionError
                 raise luma.core.error.DevicePermissionError(
                     'I2C device permission denied: {}'.format(e.filename))
@@ -73,9 +75,20 @@ class i2c(object):
 
         :param cmd: a spread of commands
         :type cmd: int
+        :raises luma.core.error.DeviceNotFoundError: I2C device could not be found.
         """
         assert(len(cmd) <= 32)
-        self._bus.write_i2c_block_data(self._addr, self._cmd_mode, list(cmd))
+
+        try:
+            self._bus.write_i2c_block_data(self._addr, self._cmd_mode,
+                                           list(cmd))
+        except OSError as e:
+            if e.errno in [errno.EREMOTEIO, errno.EIO]:
+                # I/O error
+                raise luma.core.error.DeviceNotFoundError(
+                    'I2C device not found on address: {}'.format(self._addr))
+            else:  # pragma: no cover
+                raise
 
     def data(self, data):
         """
@@ -100,6 +113,8 @@ class i2c(object):
         self._bus.close()
 
 
+@lib.spidev
+@lib.rpi_gpio
 class spi(object):
     """
     Wraps an `SPI <https://en.wikipedia.org/wiki/Serial_Peripheral_Interface_Bus>`_
@@ -155,24 +170,6 @@ class spi(object):
         self._gpio.output(self._bcm_RST, self._gpio.LOW)   # Reset device
         self._gpio.output(self._bcm_RST, self._gpio.HIGH)  # Keep RESET pulled high
 
-    def __rpi_gpio__(self):
-        # RPi.GPIO _really_ doesn't like being run on anything other than
-        # a Raspberry Pi... this is imported here so we can swap out the
-        # implementation for a mock
-        try:
-            import RPi.GPIO
-            return RPi.GPIO
-        except RuntimeError as e:
-            if str(e) == 'This module can only be run on a Raspberry Pi!':
-                raise luma.core.error.UnsupportedPlatform(
-                    'GPIO access not available')
-
-    def __spidev__(self):
-        # spidev cant compile on macOS, so use a similar technique to
-        # initialize (mainly so the tests run unhindered)
-        import spidev
-        return spidev.SpiDev()
-
     def command(self, *cmd):
         """
         Sends a command or sequence of commands through to the SPI device.


Test patch:
diff --git a/tests/test_serial.py b/tests/test_serial.py
index 5c22381..85ed96f 100644
--- a/tests/test_serial.py
+++ b/tests/test_serial.py
@@ -7,6 +7,8 @@
 Tests for the :py:mod:`luma.core.serial` module.
 """
 
+import errno
+
 try:
     from unittest.mock import patch, call, Mock
 except ImportError:
@@ -82,6 +84,25 @@ def test_i2c_command():
     smbus.write_i2c_block_data.assert_called_once_with(0x83, 0x00, cmds)
 
 
+def test_i2c_command_device_not_found_error():
+    errorbus = Mock(unsafe=True)
+    address = 0x71
+    cmds = [3, 1, 4, 2]
+    expected_error = OSError()
+
+    with patch.object(errorbus, 'write_i2c_block_data') as broken_command:
+        for error_code in [errno.EREMOTEIO, errno.EIO]:
+            expected_error.errno = error_code
+            broken_command.side_effect = expected_error
+
+            serial = i2c(bus=errorbus, address=address)
+            with pytest.raises(luma.core.error.DeviceNotFoundError) as ex:
+                serial.command(*cmds)
+
+            assert str(ex.value) == 'I2C device not found on address: {}'.format(
+                address)
+
+
 def test_i2c_data():
     data = list(fib(10))
     serial = i2c(bus=smbus, address=0x21)