Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for settings #4

Merged
merged 5 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 35 additions & 3 deletions itchcraft/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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')
Expand All @@ -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)
5 changes: 4 additions & 1 deletion itchcraft/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from abc import ABC, abstractmethod

from .prefs import Preferences


class Device(ABC):
"""Abstraction for a bite healer."""

Expand All @@ -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."""
38 changes: 34 additions & 4 deletions itchcraft/heat_it.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -13,6 +15,7 @@
from .backend import BulkTransferDevice
from .device import Device
from .logging import get_logger
from .prefs import Preferences


RESPONSE_LENGTH = 12
Expand All @@ -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(
Expand All @@ -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.')
Expand Down
110 changes: 110 additions & 0 deletions itchcraft/prefs.py
Original file line number Diff line number Diff line change
@@ -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()
Loading