thijstriemstra: Want to add tests before merging it.
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.

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..
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)

pip install -e .[test]
parse_log_pytest
pytest
pytest pytest-cov flake8
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
flake8==7.2.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
-e git+https://github.com/rm-hull/luma.core.git@78675e077c6e560afafb3811e4a31bc589857e34#egg=luma.core
mccabe==0.7.0
mock==5.2.0
packaging @ file:///croot/packaging_1734472117206/work
pillow==11.1.0
pluggy @ file:///croot/pluggy_1733169602837/work
pycodestyle==2.13.0
pyflakes==3.3.1
pytest @ file:///croot/pytest_1738938843180/work
pytest-cov==6.0.0
RPi.GPIO==0.7.1
smbus2==0.5.0
spidev==3.6
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work

name: luma.core
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - flake8==7.2.0
      - mccabe==0.7.0
      - mock==5.2.0
      - pillow==11.1.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pytest-cov==6.0.0
      - rpi-gpio==0.7.1
      - smbus2==0.5.0
      - spidev==3.6
prefix: /opt/conda/envs/luma.core

tests/test_serial.py::test_i2c_command_device_not_found_error
tests/test_serial.py::test_i2c_init_device_permission_error
tests/test_serial.py::test_i2c_init_device_not_found tests/test_serial.py::test_i2c_init_device_address_error tests/test_serial.py::test_i2c_init_no_bus tests/test_serial.py::test_i2c_init_bus_provided tests/test_serial.py::test_i2c_command tests/test_serial.py::test_i2c_data tests/test_serial.py::test_i2c_data_chunked tests/test_serial.py::test_i2c_cleanup tests/test_serial.py::test_spi_init tests/test_serial.py::test_spi_init_invalid_bus_speed tests/test_serial.py::test_spi_command tests/test_serial.py::test_spi_data tests/test_serial.py::test_spi_cleanup tests/test_serial.py::test_spi_init_device_not_found tests/test_serial.py::test_spi_unsupported_gpio_platform
luma/core/lib.py luma/core/serial.py
luma/core/lib.py luma/core/serial.py