diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e46ca3..96fa01b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,10 +6,12 @@ "editor.defaultFormatter": "vscode.json-language-features" }, "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff" + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true }, "[toml]": { - "editor.defaultFormatter": "tamasfe.even-better-toml" + "editor.defaultFormatter": "tamasfe.even-better-toml", + "editor.formatOnSave": true }, "code-runner.clearPreviousOutput": true, "code-runner.executorMap": { diff --git a/README.md b/README.md index d94a358..58a3f1a 100644 --- a/README.md +++ b/README.md @@ -135,14 +135,18 @@ interesting to you for those purposes. ## Features -At the moment, Itchcraft offers only a single feature: +At the moment, Itchcraft offers the following features: -- Activate your insect bite healer with the lowest and safest setting: - the setting for children with sensitive skin and the shortest possible - duration. +- Activate a technical demonstration of your insect bite healer + using the command line -More features, including a graphical front-end and stronger settings, -are planned. +- Choose a duration: short, medium, or long + +- Choose a generation: child or adult + +- Choose a skin sensitivity level: regular skin or sensitive skin + +A graphical front-end is planned. ## System requirements diff --git a/USAGE.md b/USAGE.md index f2d0073..be267ad 100644 --- a/USAGE.md +++ b/USAGE.md @@ -22,6 +22,34 @@ itchcraft COMMAND : Activates (i.e. heats up) a connected USB bite healer for : demonstration purposes. +# Flags + +The `start` command supports the following flags: + +## `-d`, `--duration=DURATION` + +The duration of the demonstration. + +One of `short`, `medium`, or `long`. + +The default is `short`, the safest setting. + +## `-g`, `--generation=GENERATION` + +Whether the demonstration corresponds to treating an adult or a child. + +One of the values `child` or `adult`. + +The default is `child`, the safer setting of the two. + +## `-s`, `--skin_sensitivity=SKIN_SENSITIVITY` + +Whether the tech demo caters to regular or particularly sensitive skin. + +One of the values `regular` or `sensitive`. + +The default is `sensitive`, the safer setting of the two. + # Monitoring the bite healer’s state once activated ## Monitoring the state by observing the LED color (recommended) diff --git a/itchcraft/api.py b/itchcraft/api.py index c77c270..0e5a8c8 100644 --- a/itchcraft/api.py +++ b/itchcraft/api.py @@ -2,9 +2,16 @@ from contextlib import ExitStack -from . import devices +from . import devices, prefs from .errors import CliError from .logging import get_logger +from .prefs import ( + CliEnum, + Duration, + Generation, + Preferences, + SkinSensitivity, +) logger = get_logger(__name__) @@ -13,11 +20,35 @@ class Api: """Tech demo for interfacing with heat-based USB insect bite healers""" - def start(self) -> None: # pylint: disable=no-self-use + # pylint: disable=no-self-use + def start( + self, + duration: CliEnum[Duration] = prefs.default(Duration), + generation: CliEnum[Generation] = prefs.default(Generation), + skin_sensitivity: CliEnum[SkinSensitivity] = prefs.default( + SkinSensitivity + ), + ) -> None: # pylint: disable=no-self-use """Activates (i.e. heats up) a connected USB bite healer for demonstration purposes. + + :param duration: + One of `short`, `medium`, or `long`. + + :param generation: + `child` or `adult`. + + :param skin_sensitivity: + `regular` or `sensitive`. """ + preferences = Preferences( + duration=prefs.parse(duration, Duration), + generation=prefs.parse(generation, Generation), + skin_sensitivity=prefs.parse( + skin_sensitivity, SkinSensitivity + ), + ) logger.warning('This app is only a tech demo') logger.warning('and NOT for medical use.') logger.warning('The app is NOT SAFE to use') @@ -43,5 +74,6 @@ def start(self) -> None: # pylint: disable=no-self-use 'Itchcraft can only use one device at a time.' ) + logger.info('Using settings: %s', preferences) device.self_test() - device.start_heating() + device.start_heating(preferences) diff --git a/itchcraft/device.py b/itchcraft/device.py index c52aea2..c59eef3 100644 --- a/itchcraft/device.py +++ b/itchcraft/device.py @@ -2,6 +2,9 @@ from abc import ABC, abstractmethod +from .prefs import Preferences + + class Device(ABC): """Abstraction for a bite healer.""" @@ -11,5 +14,5 @@ def self_test(self) -> None: functional.""" @abstractmethod - def start_heating(self) -> None: + def start_heating(self, preferences: Preferences) -> None: """Tells the device to start heating up.""" diff --git a/itchcraft/heat_it.py b/itchcraft/heat_it.py index bc81c80..a24df9e 100644 --- a/itchcraft/heat_it.py +++ b/itchcraft/heat_it.py @@ -1,5 +1,7 @@ """Backend for heat-it""" +from collections.abc import Iterable +from functools import reduce from typing import Optional, Union from tenacity import ( @@ -13,6 +15,7 @@ from .backend import BulkTransferDevice from .device import Device from .logging import get_logger +from .prefs import Preferences RESPONSE_LENGTH = 12 @@ -38,12 +41,37 @@ def get_status(self) -> bytes: """Issues a `GET_STATUS` command and returns the response.""" return self._command([0xFF, 0x02, 0x02], 'GET_STATUS') - def msg_start_heating(self) -> bytes: + def msg_start_heating(self, preferences: Preferences) -> bytes: """Issues a `MSG_START_HEATING` command and returns the response. """ + + def duration_code() -> int: + return preferences.duration.value - 1 + + def generation_code() -> int: + return preferences.generation.value - 1 + + def skin_sensitivity_code() -> int: + return preferences.skin_sensitivity.value - 1 + + def payload() -> list[int]: + return [ + 0x08, + (generation_code() << 1) + skin_sensitivity_code(), + duration_code(), + ] + + def checksum(payload: Iterable[int]) -> int: + return reduce(int.__add__, payload) + return self._command( - [0xFF, 0x08, 0x00, 0x00, 0x08], 'MSG_START_HEATING' + [ + 0xFF, + *payload(), + checksum(payload()), + ], + 'MSG_START_HEATING', ) def _command( @@ -70,9 +98,11 @@ def self_test(self) -> None: logger.debug('Response: %s', self.test_bootloader().hex(' ')) logger.debug('Response: %s', self.get_status().hex(' ')) - def start_heating(self) -> None: + def start_heating(self, preferences: Preferences) -> None: """Tells the device to start heating up.""" - logger.debug('Response: %s', self.msg_start_heating().hex(' ')) + logger.debug( + 'Response: %s', self.msg_start_heating(preferences).hex(' ') + ) logger.info('Device now preheating.') logger.info('Watch the LED closely.') diff --git a/itchcraft/prefs.py b/itchcraft/prefs.py new file mode 100644 index 0000000..961f316 --- /dev/null +++ b/itchcraft/prefs.py @@ -0,0 +1,110 @@ +"""User preferences""" + +from dataclasses import dataclass, field +from enum import Enum +import re +from typing import TypeVar, Union + +from .errors import CliError + +E = TypeVar('E', bound=Enum) + +# If a CLI switch is backed by an enum, then allow the enum to stand in +# for that switch +CliEnum = Union[str, E] + + +class SkinSensitivity(Enum): + """Whether or not a person’s skin is particularly sensitive.""" + + SENSITIVE = 1 + REGULAR = 2 + + def __str__(self) -> str: + return f'{self.name.lower()} skin' + + +class Generation(Enum): + """The age cohort of a person.""" + + CHILD = 1 + ADULT = 2 + + def __str__(self) -> str: + return self.name.lower() + + +class Duration(Enum): + """The duration of a demo session.""" + + SHORT = 1 + MEDIUM = 2 + LONG = 3 + + def __str__(self) -> str: + return f'{self.name.lower()} duration' + + +@dataclass(frozen=True) +class Preferences: + """User preferences for a bite healer demo session.""" + + skin_sensitivity: SkinSensitivity = field( + default=SkinSensitivity.SENSITIVE + ) + generation: Generation = field(default=Generation.CHILD) + duration: Duration = field(default=Duration.SHORT) + + def __str__(self) -> str: + return ', '.join( + str(attr) + for attr in ( + self.duration, + self.generation, + self.skin_sensitivity, + ) + ) + + +def default(enum_type: type[E]) -> str: + """Returns the default preference for a given Enum type. + + :param enum_type: + Enum type which exists as an attribute in Preferences and + whose corresponding attribute name is equal to the type name + converted to snake case. + """ + default_value: E = getattr(Preferences, _snake_case_name(enum_type)) + return default_value.name.lower() + + +# pylint: disable=raise-missing-from +def parse(value: CliEnum[E], enum_type: type[E]) -> E: + """Parses a given value into an Enum if it isn’t one yet. + Returns the value itself if it’s already an Enum. + + :param value: + an Enum value or a corresponding name, written in lower case. + + :param enum_type: + the type of the Enum to parse into. + """ + if isinstance(value, enum_type): + return value + assert isinstance(value, str) + try: + return enum_type[value.upper()] + except KeyError: + raise CliError( + f'Invalid value `{value}`. Valid values for {_snake_case_name(enum_type)} are: ' + + ', '.join([key.lower() for key in enum_type.__members__]), + ) + + +def _snake_case_name(camel_case_type: type) -> str: + """Returns the name of the given type converted into snake case.""" + return re.sub( + r'(?:\B|\Z)([A-Z])', + lambda match: f'_{match.group(1)}', + camel_case_type.__name__, + ).lower() diff --git a/tests/test_heat_it.py b/tests/test_heat_it.py index cdc0fab..394f474 100644 --- a/tests/test_heat_it.py +++ b/tests/test_heat_it.py @@ -5,7 +5,7 @@ AbstractContextManager, nullcontext, ) -from typing import Optional, Union +from typing import Optional, TypedDict, Union from unittest.mock import ANY import pytest @@ -15,6 +15,20 @@ from itchcraft.backend import BulkTransferDevice from itchcraft.device import Device from itchcraft.heat_it import HeatItDevice +from itchcraft.prefs import ( + CliEnum, + Duration, + Generation, + SkinSensitivity, +) + + +class StartParams(TypedDict, total=False): + """Parameters for the `start` method in parameterized tests.""" + + duration: CliEnum[Duration] + generation: CliEnum[Generation] + skin_sensitivity: CliEnum[SkinSensitivity] @pytest.fixture(name='dummy_device') @@ -29,23 +43,534 @@ def fixture_find_dummy_device( return lambda: iter((dummy_device,)) -def test_default( +@pytest.fixture(name='bulk_transfer') +def fixture_bulk_transfer( + mocker: pytest_mock.MockerFixture, monkeypatch: pytest.MonkeyPatch, find_dummy_device: Iterator[AbstractContextManager[Device]], - mocker: pytest_mock.MockerFixture, -) -> None: +) -> Iterator[pytest_mock.MockType]: monkeypatch.setattr(devices, 'find_devices', find_dummy_device) - bulk_transfer = mocker.spy( _DummyUsbBulkTransferDevice, 'bulk_transfer' ) - Api().start() + yield bulk_transfer assert bulk_transfer.call_count == 3 + + +def test_no_preferences(bulk_transfer: pytest_mock.MockType) -> None: + Api().start() bulk_transfer.assert_called_with( ANY, [0xFF, 0x08, 0x00, 0x00, 0x08] ) +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(), + StartParams(duration='short'), + StartParams(duration=Duration.SHORT), + StartParams(duration='short', generation='child'), + StartParams( + duration=Duration.SHORT, generation=Generation.CHILD + ), + StartParams(generation='child'), + StartParams(generation=Generation.CHILD), + StartParams(duration=Duration.SHORT), + StartParams(duration='short', skin_sensitivity='sensitive'), + StartParams( + duration=Duration.SHORT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + StartParams(skin_sensitivity='sensitive'), + StartParams(skin_sensitivity=SkinSensitivity.SENSITIVE), + StartParams( + duration='short', + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_child_sensitive_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x00, 0x00, 0x08], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='medium'), + StartParams(duration=Duration.MEDIUM), + StartParams(duration='medium', generation='child'), + StartParams( + duration=Duration.MEDIUM, generation=Generation.CHILD + ), + StartParams(duration='medium', skin_sensitivity='sensitive'), + StartParams( + duration=Duration.MEDIUM, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + StartParams( + duration='medium', + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_child_sensitive_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x00, 0x01, 0x09], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='long'), + StartParams(duration=Duration.LONG), + StartParams(duration='long', generation='child'), + StartParams( + duration=Duration.LONG, generation=Generation.CHILD + ), + StartParams(duration='long', skin_sensitivity='sensitive'), + StartParams( + duration=Duration.LONG, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + StartParams( + duration='long', + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation='child', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_child_sensitive_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x00, 0x02, 0x0A], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='short', generation='adult'), + StartParams( + duration=Duration.SHORT, generation=Generation.ADULT + ), + StartParams(generation='adult'), + StartParams(generation=Generation.ADULT), + StartParams( + duration='short', + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_adult_sensitive_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x02, 0x00, 0x0A], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='medium', generation='adult'), + StartParams( + duration=Duration.MEDIUM, generation=Generation.ADULT + ), + StartParams( + duration='medium', + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_adult_sensitive_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x02, 0x01, 0x0B], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='long', generation='adult'), + StartParams( + duration=Duration.LONG, generation=Generation.ADULT + ), + StartParams( + duration='long', + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation='adult', + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity='sensitive', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.SENSITIVE, + ), + ], +) +def test_adult_sensitive_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x02, 0x02, 0x0C], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='short', skin_sensitivity='regular'), + StartParams( + duration=Duration.SHORT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams(skin_sensitivity='regular'), + StartParams(skin_sensitivity=SkinSensitivity.REGULAR), + StartParams( + duration='short', + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_child_regular_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x01, 0x00, 0x09], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='medium', skin_sensitivity='regular'), + StartParams( + duration=Duration.MEDIUM, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams( + duration='medium', + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_child_regular_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x01, 0x01, 0x0A], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(duration='long', skin_sensitivity='regular'), + StartParams( + duration=Duration.LONG, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams( + duration='long', + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation='child', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.CHILD, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_child_regular_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x01, 0x02, 0x0B], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams(generation='adult', skin_sensitivity='regular'), + StartParams( + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + StartParams( + duration='short', + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.SHORT, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_adult_regular_short( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x03, 0x00, 0x0B], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams( + duration='medium', + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.MEDIUM, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_adult_regular_medium( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x03, 0x01, 0x0C], + ) + + +@pytest.mark.parametrize( + 'preferences', + [ + StartParams( + duration='long', + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation='adult', + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity='regular', + ), + StartParams( + duration=Duration.LONG, + generation=Generation.ADULT, + skin_sensitivity=SkinSensitivity.REGULAR, + ), + ], +) +def test_adult_regular_long( + bulk_transfer: pytest_mock.MockType, + preferences: StartParams, +) -> None: + Api().start(**preferences) + bulk_transfer.assert_called_with( + ANY, + [0xFF, 0x08, 0x03, 0x02, 0x0D], + ) + + class _DummyUsbBulkTransferDevice(BulkTransferDevice): def bulk_transfer( self, request: Union[list[int], bytes, bytearray]