import logging
from abc import ABC, abstractmethod
from math import sqrt
from time import time
from typing import Optional, Union, Tuple
import functools
from evdev import InputEvent, ff, ecodes
from approxeng.input.sys import sys_nodes
#: Logger - explicitly set the level for this to see log messages
logger = logging.getLogger(name='approxeng.input')
def map_into_range(low, high, raw_value):
"""
Map an input function into an output value, clamping such that the magnitude of the output is at most 1.0
:param low:
The value in the input range corresponding to zero.
:param high:
The value in the input range corresponding to 1.0 or -1.0, depending on whether this is higher or lower than the
low value respectively.
:param raw_value:
An input value
:return:
Mapped output value
"""
value = float(raw_value)
if low < high:
if value < low:
return 0
elif value > high:
return 1.0
elif low > high:
if value > low:
return 0
elif value < high:
return -1.0
return (value - low) / abs(high - low)
def map_single_axis(low, high, dead_zone, hot_zone, value):
"""
Apply dead and hot zones before mapping a value to a range. The dead and hot zones are both expressed as the
proportion of the axis range which should be regarded as 0.0 or 1.0 (or -1.0 depending on cardinality) respectively,
so for example setting dead zone to 0.2 means the first 20% of the range of the axis will be treated as if it's the
low value, and setting the hot zone to 0.4 means the last 40% of the range will be treated as if it's the high
value. Note that as with map_into_range, low is not necessarily numerically lower than high, it instead expresses
a low value signal as opposed to a high value one (which could include a high negative value). Note that bad things
happen if dead_zone + hot_zone == 1.0, so don't do that. This is used by the map_dual_axis call, but can also be
used by itself to handle single axes like triggers where the overall range varies from 0.0 to 1.0 rather than -1.0
to 1.0 as a regular joystick axis would.
:param low:
The value corresponding to no signal
:param high:
The value corresponding to a full signal
:param dead_zone:
The proportion of the range of motion away from the no-signal end which should be treated as equivalent to no
signal and return 0.0
:param hot_zone:
The proportion of the range of motion away from the high signal end which should be treated as equivalent to a
full strength input.
:param value:
The raw value to map
:return:
The scaled and clipped value, taking into account dead and hot zone boundaries, ranging from 0.0 to either 1.0
or -1.0 depending on whether low or high are numerically larger (low < high means max value is 1.0, high < low
means it's -1.0).
"""
input_range = high - low
corrected_low = low + input_range * dead_zone
corrected_high = high - input_range * hot_zone
return map_into_range(corrected_low, corrected_high, value)
def map_dual_axis(low, high, centre, dead_zone, hot_zone, value):
"""
Map an axis with a central dead zone and hot zones at each end to a range from -1.0 to 1.0. This in effect uses two
calls to map_single_axis, choosing whether to use centre and low, or centre and high as the low and high values in
that call based on which side of the centre value the input value falls. This is the call that handles mapping of
values on regular joysticks where there's a centre point to which the physical control returns when no input is
being made.
:param low:
The raw value corresponding to the strongest negative input (stick far left / down).
:param high:
The raw value corresponding to the strongest positive input (stick far right / up).
:param centre:
The raw value corresponding to the resting position of the axis when no user interaction is happening.
:param dead_zone:
The proportion of each (positive and negative) part of the motion away from the centre which should result in
an output of 0.0
:param hot_zone:
The proportion of each (positive and negative) part of the motion away from each extreme end of the range which
should result in 1.0 or -1.0 being returned (depending on whether we're on the high or low side of the centre
point)
:param value:
The raw value to map
:return:
The filtered and clamped value, from -1.0 at low to 1.0 at high, with a centre as specified mapping to 0.0
"""
if value <= centre:
return map_single_axis(centre, low, dead_zone, hot_zone, value)
else:
return map_single_axis(centre, high, dead_zone, hot_zone, value)
[docs]
class Controller(ABC):
"""
Superclass for controller implementations
:ivar approxeng.input.Axes axes:
All analogue axes. You can get the individual axis objects from this, but you shouldn't ever need to do this,
use methods on Controller instead!
:ivar approxeng.input.Buttons buttons:
All buttons are managed by this object. This can be used to access Button objects representing buttons on the
controller, but you will almost never need to do this - use the methods on Controller instead!
"""
[docs]
def __init__(self, controls, node_mappings=None, dead_zone=None,
hot_zone=None, ff_device=None):
"""
Populate the controller name, button set and axis set.
:param controls:
A list of :class:`~approxeng.input.Button`, :class:`~approxeng.input.CentredAxis`,
:class:`~approxeng.input.TriggerAxis` and :class:`~approxeng.input.BinaryAxis` instances
:param node_mappings:
A dict from device name to a prefix which will be applied to all events from nodes with a
matching name before dispatching the corresponding events. This is used to handle controller
types which create multiple nodes in /dev/input by keying on the device names reported to evdev
for each node. Nodes are grouped by physical or unique ID first so should, in an ideal world at least,
all correspond to the same physical controller. This is necessary to support some controllers on modern
kernels, particularly 4.15. If not specified, or none, then no per-node renaming is applied. Device
names which do not appear in this map are not assigned a prefix, so it's legitimate to only assign
prefixes for 'new' functionality which has magically appeared in a later kernel. Similarly, this is
ignored if there is only one device node bound to the controller instance, so the best practice is to
leave the older mappings named simply by their code, and only use this to handle secondary device nodes
such as motion sensors.
:param dead_zone:
If specified, this is applied to all axes
:param hot_zone:
If specified, this is applied to all axes
:param ff_device:
If specified, this is a force feedback compatible device node, defaults to None
"""
self.axes = Axes([control for control in controls if
isinstance(control, CentredAxis) or
isinstance(control, BinaryAxis) or
isinstance(control, TriggerAxis)])
self.buttons = Buttons([control for control in controls if
isinstance(control, Button) or
isinstance(control, BinaryAxis) or
isinstance(control, TriggerAxis)])
if dead_zone is not None:
for axis in self.axes.axes:
axis.dead_zone = dead_zone
if hot_zone is not None:
for axis in self.axes.axes:
axis.hot_zone = hot_zone
self.node_mappings = node_mappings
self.device_unique_name = None
self.exception = None
self.ff_device = ff_device
class ControllerStream(object):
"""
Class to produce streams for values from the parent controller on demand.
"""
def __init__(self, controller):
self.controller = controller
def __getitem__(self, item):
"""
:param item:
Name of an item or items to fetch, referring to them by sname, so either axes or
buttons.
:return:
A generator which will emit the value of that item or items every time it's called, in effect
creating an infinite stream of values for the given item or items.
"""
def generator():
while self.controller.connected:
yield self.controller.__getitem__(item)
return generator()
self.stream = ControllerStream(self)
@functools.lru_cache(maxsize=None)
def _build_effect(self, milliseconds=1000, strong_magnitude=0x0000, weak_magnitude=0xffff) -> int:
"""
Compile and send a new force feedback effect, caching so we don't over-fill whatever memory the device
is using to store effect programs. I've no idea whether this is actually an issue but why risk it?
:param milliseconds:
milliseconds to run the effect
:param strong_magnitude:
strong magnitude, defaults to 0x0000
:param weak_magnitude:
weak magnitude, defaults to 0xffff (no, I don't know why these are swapped either, just following the
examples!)
:raise ValueError:
if the controller does not have a force-feedback compatible device node
:return:
id of the upload effect block, this is then used later to actually trigger the effect
"""
if self.ff_device:
logger.info('compiling new force feedback effect')
effect = ff.Effect(
ecodes.FF_RUMBLE, -1, 0,
ff.Trigger(0, 0),
ff.Replay(milliseconds, 0),
ff.EffectType(
ff_rumble_effect=ff.Rumble(strong_magnitude=strong_magnitude, weak_magnitude=weak_magnitude))
)
return self.ff_device.upload_effect(effect)
else:
raise ValueError('no force-feedback node, unable to compile effect')
[docs]
def rumble(self, milliseconds=1000):
"""
Trigger a force-feedback effect, compiling and sending to the device if necessary first, otherwise using
an existing effect that's already been compiled.
:param milliseconds:
milliseconds of rumbling required
"""
if self.ff_device:
logger.debug('controller go brrrr')
effect_id = self._build_effect(milliseconds=milliseconds)
repeat_count = 1
self.ff_device.write(ecodes.EV_FF, effect_id, repeat_count)
else:
logger.warning('no force-feedback node for this controller')
@property
def has_force_feedback(self):
return self.ff_device is not None
@property
def sys_nodes(self) -> dict:
"""
Returns a dict of discovered sys nodes representing power and LED status for this controller. If the controller
isn't bound to a physical device, or there aren't LED or power nodes available this returns an empty dict.
"""
if self.device_unique_name is not None:
return sys_nodes(self.device_unique_name)
return {}
[docs]
def read_led_value(self, led_name) -> Optional[int]:
"""
Read an existing LED value. This may or may not work depending on the underlying implementation. Requires
a bound controller with the specified name present in its LED sys classes. Returns None if this does not
apply.
:param led_name:
Name of LED to query
:return:
Integer value of LED, or None if either unbound or no such LED name
"""
if self.device_unique_name is not None:
return sys.read_led_value(self.device_unique_name, led_name)
return None
[docs]
def write_led_value(self, led_name: str, value: int):
"""
Write a value to a named LED. Does nothing if either we're not bound to a device, or there's no
such LED name.
:param led_name:
LED name within this device - use self.sys_nodes['leds'] keys to discover LED names.
:param value:
Value to write, should be an integer.
"""
if self.device_unique_name is not None:
sys.write_led_value(self.device_unique_name, led_name, value)
@property
def battery_level(self) -> Optional[float]:
"""
Read the battery capacity, if available, as a percentage. If not available, return None
"""
if self.device_unique_name is not None:
return sys.read_power_level(self.device_unique_name)
return None
@staticmethod
@abstractmethod
def registration_ids() -> [Tuple[int, int]]:
pass
@property
def connected(self) -> bool:
"""
:return:
True if the controller object is associated correctly with a physical device, False otherwise. Use this
to detect a loss of controller pairing.
"""
if self.device_unique_name:
return True
return False
def __getitem__(self, item: Union[str, Tuple[str, ...]]) -> [Optional[float]]:
"""
Simple index access to axis corrected values and button held times
:param item:
the sname of an axis or button, or a tuple thereof
:return:
for an axis, the corrected value, or, for a button, the held time or None if not held. Raises AttributeError
if the given name doesn't correspond to an axis or a button. If a tuple is supplied as an argument, result
will be a tuple of values.
"""
if isinstance(item, tuple):
return [self.__getattr__(single_item) for single_item in item]
return self.__getattr__(item)
def __getattr__(self, item: str) -> Optional[float]:
"""
Property access to axis values and button hold times
:param item:
sname of an axis or button
:return:
The axis corrected value, or button hold time (None if not held), or AttributeError if sname not found
"""
if item in self.axes:
return self.axes[item].value
elif item in self.buttons:
return self.buttons.held(item)
raise AttributeError
def __contains__(self, item: str) -> bool:
"""
A Controller contains a named attribute if it has either an axis or a button with the attribute as its sname
:param item:
The sname of the button or axis
:return:
True if there's a button or axis with that name, false otherwise
"""
if item in self.axes:
return True
if item in self.buttons:
return True
return False
[docs]
def check_presses(self) -> 'ButtonPresses':
"""
Return the set of Buttons which have been pressed since this call was last made, clearing it as we do. This is
a shortcut to doing 'buttons.get_and_clear_button_press_history'
:return:
A ButtonPresses instance which contains buttons which were pressed since this call was last made.
"""
return self.buttons.check_presses()
@property
def has_presses(self) -> bool:
"""
:return: True if there were button presses since the last check.
"""
return self.buttons.presses.has_presses
@property
def has_releases(self) -> bool:
"""
:return: True if any buttons were released since the last check.
"""
return self.buttons.releases.has_presses
@property
def presses(self) -> 'ButtonPresses':
"""
The :class:`~approxeng.input.ButtonPresses` containing buttons pressed between the two most recent calls to
:meth:`~approxeng.input.Controller.check_presses`
"""
return self.buttons.presses
@property
def releases(self) -> 'ButtonPresses':
"""
The :class:`~approxeng.input.ButtonPresses` containing buttons released between the two most recent calls to
:meth:`~approxeng.input.Controller.check_presses`
"""
return self.buttons.releases
@property
def controls(self) -> dict:
"""
:return:
A dict containing all the names of controls on this controller, this takes the form of a dict with two
keys, `axes` and `buttons`, the values for each of which are lists of strings containing the names of each
type of control.
"""
return {'axes': self.axes.names,
'buttons': self.buttons.names}
def __str__(self) -> str:
return "{}, axes={}, buttons={}".format(self.__class__.__name__, self.axes, self.buttons)
[docs]
class Axes(object):
"""
A set of TriggerAxis or CentredAxis instances to which events should be routed based on event code. Contains methods
to reset calibration on all axes, or to centre all axes for which this is meaningful.
"""
[docs]
def __init__(self, axes):
"""
Create a new Axes instance, this will be done within the controller classes, you never have to explicitly
instantiate this yourself.
:param axes:
a sequence of :class:`approxeng.input.TriggerAxis` or :class:`approxeng.input.CentredAxis` or
:class:`approxeng.input.BinaryAxis` containing all the axes the controller supports.
"""
self.axes = axes
self.axes_by_code = {axis.axis_event_code: axis for axis in axes}
self.axes_by_sname = {axis.sname: axis for axis in axes}
# Look to see whether we've got pairs of lx,ly and / or rx,ry and create corresponding circular axes
def add_circular_axis(rootname):
xname = rootname + 'x'
yname = rootname + 'y'
if xname in self.axes_by_sname and yname in self.axes_by_sname:
self.axes_by_sname[rootname] = CircularCentredAxis(x=self.axes_by_sname[xname],
y=self.axes_by_sname[yname])
for prefix in ['l', 'r', 'd']:
add_circular_axis(prefix)
[docs]
def axis_updated(self, event: InputEvent, prefix=None):
"""
Called to process an absolute axis event from evdev, this is called internally by the controller implementations
:internal:
:param event:
The evdev event to process
:param prefix:
If present, a named prefix that should be applied to the event code when searching for the axis
"""
if prefix is not None:
axis = self.axes_by_code.get(prefix + str(event.code))
else:
axis = self.axes_by_code.get(event.code)
if axis is not None:
axis.receive_device_value(event.value)
else:
logger.debug('Unknown axis code {} ({}), value {}'.format(event.code, prefix, event.value))
[docs]
def set_axis_centres(self, *args):
"""
Sets the centre points for each axis to the current value for that axis. This centre value is used when
computing the value for the axis and is subtracted before applying any scaling. This will only be applied
to CentredAxis instances
"""
for axis in self.axes_by_code.values():
if isinstance(axis, CentredAxis):
axis.centre = axis.value
[docs]
def reset_axis_calibration(self, *args):
"""
Resets any previously defined axis calibration to 0.0 for all axes
"""
for axis in self.axes:
axis.reset()
def __str__(self):
return list("{}={}".format(axis.name, axis.value) for axis in self.axes_by_code.values()).__str__()
@property
def names(self) -> list[str]:
"""
The snames of all axis objects
"""
return sorted([name for name in self.axes_by_sname.keys() if name != ''])
@property
def active_axes(self) -> ['Axis']:
"""
Return a sequence of all Axis objects which are not in their resting positions
"""
return [axis for axis in self.axes if axis.value != 0]
def __getitem__(self, sname: str) -> Optional['Axis']:
"""
Get an axis by sname, if present
:param sname:
The standard name to search
:return:
An axis object, or None if no such axis exists
"""
return self.axes_by_sname.get(sname)
def __getattr__(self, item) -> 'Axis':
"""
Called when an unresolved attribute is requested, retrieves the Axis object for the given sname
:param item:
the standard name of the axis to query
:return:
the corrected value of the axis, or raise AttributeError if no such axis is present
:raise:
AttributeError if there's no axis with this name
"""
if item in self.axes_by_sname:
return self.get(item)
raise AttributeError
def __contains__(self, item: str) -> bool:
"""
Check whether a given axis, referenced by sname, exists
:param item:
The sname of the axis to search
:return:
True if the axis exists, false otherwise
"""
return item in self.axes_by_sname
class Axis(ABC):
"""
Abstract base class for axis types.
"""
@property
@abstractmethod
def value(self) -> float:
"""
:return:
A corrected floating point value, either in the range -1 to 1 for centred axes, or 0 to 1 for non-centred,
adjusted for dead and hot zones.
"""
pass
@abstractmethod
def receive_device_value(self, value: int):
"""
Receive a value from the underlying operating system code, in our case evdev, and update the internal state of
this axis object appropriately.
:param value:
Integer value received from the evdev event handler.
"""
pass
[docs]
class TriggerAxis(Axis):
"""
A single analogue axis where the expected output range is 0.0 to 1.0. Typically this is used for triggers, where the
resting position is 0.0 and any interaction causes higher values. Whether a particular controller exposes triggers
as axes or as buttons depends on the hardware - the PS3 front triggers appear as buttons, the XBox One triggers as
axes.
"""
[docs]
def __init__(self, name: str, min_raw_value: int, max_raw_value: int, axis_event_code: int, dead_zone=0.0,
hot_zone=0.0, sname: Optional[str] = None, button_sname: Optional[str] = None,
button_trigger_value=0.5):
"""
Create a new TriggerAxis - this will be done internally within the :class:`~approxeng.input.Controller`
sub-class.
:param name:
A friendly name for the axis
:param min_raw_value:
The value read from the event system when the trigger is not pressed
:param max_raw_value:
The value read from the event system when the trigger is fully pressed
:param axis_event_code:
The evdev code for this axis, used when dispatching events to it from an Axes object
:param dead_zone:
The proportion of the trigger range which will be treated as equivalent to no press
:param hot_zone:
The proportion of the trigger range which will be treated as equivalent to fully depressing the trigger
:param sname:
The standard name for this trigger, if specified
:param button_sname:
If provided, this creates a new Button internally which will be triggered by changes to the axis value. This
is useful for triggers which have axis representations but no corresponding button presses such as the XBox1
controller front triggers. If this is set to None then no button is created
:param button_trigger_value:
Defaulting to 0.5, this value determines the point in the trigger axis' range at which point the button is
regarded as being pressed or released.
"""
self.name = name
self.max = 0.9
self.min = 0.1
self.__value = self.min
self.dead_zone = dead_zone
self.hot_zone = hot_zone
self.min_raw_value = min_raw_value
self.max_raw_value = max_raw_value
self.axis_event_code = axis_event_code
self.sname = sname
self.buttons = None
self.button_trigger_value = button_trigger_value
if button_sname is not None:
self.button = Button(name='{}_trigger_button'.format(name),
key_code='{}_trigger_button'.format(axis_event_code),
sname=button_sname)
else:
self.button = None
def _input_to_raw_value(self, value: int) -> float:
"""
Convert the value read from evdev to a 0.0 to 1.0 range.
:internal:
:param value:
a value ranging from the defined minimum to the defined maximum value.
:return:
0.0 at minimum, 1.0 at maximum, linearly interpolating between those two points.
"""
return (float(value) - self.min_raw_value) / self.max_raw_value
@property
def raw_value(self) -> float:
"""
Get an uncorrected value for this trigger
:return: a float value, 0.0 when not pressed, to 1.0 when fully pressed
"""
return self.__value
@property
def value(self) -> float:
"""
Get a centre-compensated, scaled, value for the axis, taking any dead-zone into account. The value will
scale from 0.0 at the edge of the dead-zone to 1.0 (positive) at the extreme position of
the trigger or the edge of the hot zone, if defined as other than 1.0.
:return:
a float value, 0.0 when not pressed or within the dead zone, to 1.0 when fully pressed or in the hot zone
"""
return map_single_axis(self.min, self.max, self.dead_zone, self.hot_zone, self.__value)
[docs]
def reset(self):
"""
Reset calibration (max, min and centre values) for this axis specifically.
:internal:
"""
self.max = 0.9
self.min = 0.1
[docs]
def receive_device_value(self, raw_value: int):
"""
Set a new value, called from within the joystick implementation class when parsing the event queue.
:param raw_value: the raw value from the joystick hardware
:internal:
"""
new_value = self._input_to_raw_value(raw_value)
if self.button is not None:
if new_value > (self.button_trigger_value + 0.05) > self.__value:
self.buttons.button_pressed(self.button.key_code)
elif new_value < (self.button_trigger_value - 0.05) < self.__value:
self.buttons.button_released(self.button.key_code)
self.__value = new_value
if new_value > self.max:
self.max = new_value
elif new_value < self.min:
self.min = new_value
def __str__(self):
return "TriggerAxis name={}, sname={}, corrected_value={}".format(self.name, self.sname, self.value)
[docs]
class BinaryAxis(Axis):
"""
A fake 'analogue' axis which actually corresponds to a pair of buttons. Once associated with a Buttons instance
it routes events through to the Buttons instance to create button presses corresponding to axis movements. This is
necessary as some controllers expose buttons, especially D-pad buttons, as a pair of axes rather than four buttons,
but we almost certainly want to treat them as buttons the way most controllers do.
"""
[docs]
def __init__(self, name, axis_event_code, b1name=None, b2name=None):
"""
Create a new binary axis, used to route axis events through to a pair of buttons, which are created as
part of this constructor
:param name:
Name for the axis, use this to describe the axis, it's not used for anything else
:param axis_event_code:
The evdev event code for changes to this axis
:param b1name:
The sname of the button corresponding to negative values of the axis.
:param b2name:
The sname of the button corresponding to positive values of the axis
"""
self.name = name
self.axis_event_code = axis_event_code
self.b1 = Button('{}_left_button'.format(name), key_code='{}_left'.format(axis_event_code), sname=b1name)
self.b2 = Button('{}_right_button'.format(name), key_code='{}_right'.format(axis_event_code), sname=b2name)
self.buttons = None
self.last_value = 0
self.sname = ''
self.__value = 0
[docs]
def receive_device_value(self, raw_value: int):
self.__value = raw_value
if self.buttons is not None:
if self.last_value < 0:
self.buttons.button_released(self.b1.key_code)
elif self.last_value > 0:
self.buttons.button_released(self.b2.key_code)
self.last_value = raw_value
if raw_value < 0:
self.buttons.button_pressed(self.b1.key_code)
elif raw_value > 0:
self.buttons.button_pressed(self.b2.key_code)
@property
def value(self):
"""
You probably don't want to actually get the value of this axis, use the generated buttons instead.
:returns int:
The raw value from the evdev events driving this axis.
"""
return self.__value
def __str__(self):
return "BinaryAxis name={}, sname={}, corrected_value={}".format(self.name, self.sname, self.value)
[docs]
class CircularCentredAxis:
"""
An aggregation of a pair of :class:`~approxeng.input.CentredAxis` instances.
When using a pair of centred axes to model a single joystick there are some unexpected and probably undesirable
issues with dead zones. As each axis is treated independently, the dead zones are also applied independently - this
means that, for example, with the joystick fully pushed forwards you still have the dead zone behaviour between left
and right. You may prefer a behaviour where both axes are zero if the stick is within a certain distance of its
centre position in any direction. This class provides that, and is created from a pair of centred axes, i.e. 'lx'
and 'ly'. The value is returns is a tuple of (x,y) positions. Use of this class will constrain the overall motion
of the paired axes into the unit circle - in many controllers this is true because of the physical layout of the
controller, but it may not always be in hardware terms.
"""
[docs]
def __init__(self, x: "CentredAxis", y: "CentredAxis", dead_zone=0.1, hot_zone=0.1):
"""
Create a new circular centred axis
:param CentredAxis x:
Axis to use for x value
:param CentredAxis y:
Axis to use for y value
:param float dead_zone:
Specifies the distance from the centre prior to which both x and y will return 0.0, defaults to 0.1
:param float hot_zone:
Specifies the distance from the 1.0 distance beyond which both x and y will return +-1.0, i.e. if the hot
zone is set to 0.1 then all positions where the distance is greater than 0.9 will return magnitude 1 total
distances. Defaults to 0.1
"""
self.x = x
self.y = y
self.dead_zone = dead_zone
self.hot_zone = hot_zone
def _calculate_position(self, raw_x: float, raw_y: float):
"""
Map x and y to a corrected x,y tuple based on the configured dead and hot zones.
:param raw_x:
Raw x axis position, -1.0 to 1.0
:param raw_y:
Raw y axis position, -1.0 to 1.0
:return:
x,y corrected position
"""
# Avoid trying to take sqrt(0) in pathological case
if raw_x != 0 or raw_y != 0:
distance = sqrt(raw_x * raw_x + raw_y * raw_y)
else:
return 0.0, 0.0
if distance >= 1.0 - self.hot_zone:
# Return normalised value, which corresponds to the unit vector in that direction
return raw_x / distance, raw_y / distance
elif distance <= self.dead_zone:
# Return zero vector
return 0.0, 0.0
# Guarantee distance to be between dead_zone and 1.0-hot_zone at this point, scale it and return
effective_distance = (distance - self.dead_zone) / (1.0 - (self.dead_zone + self.hot_zone))
scale = effective_distance / distance
return raw_x * scale, raw_y * scale
@property
def value(self) -> (float, float):
return self._calculate_position(raw_x=self.x.raw_value if not self.x.invert else -self.x.raw_value,
raw_y=self.y.raw_value if not self.y.invert else -self.y.raw_value)
[docs]
class CentredAxis(Axis):
"""
A single analogue axis on a controller where the expected output range is -1.0 to 1.0 and the resting position of
the control is at 0.0, at least in principle.
"""
[docs]
def __init__(self, name, min_raw_value, max_raw_value, axis_event_code, dead_zone=0.0, hot_zone=0.0,
sname=None):
"""
Create a new CentredAxis - this will be done internally within the :class:`~approxeng.input.Controller`
sub-class.
:param name:
A friendly name for the axis
:param min_raw_value:
The value read from the event system when the axis is at its minimum value
:param max_raw_value:
The value read from the event system when the axis is at its maximum value
:param axis_event_code:
The evdev code for this axis, used to dispatch events to the axis from the event system
:param dead_zone:
Size of the dead zone in the centre of the axis, within which all values will be mapped to 0.0
:param hot_zone:
Size of the hot zones at the ends of the axis, where values will be mapped to -1.0 or 1.0
:param sname:
The standard name for this axis, if specified
"""
self.name = name
self.centre = 0.0
self.max = 0.9
self.min = -0.9
self.__value = 0.0
self.invert = min_raw_value > max_raw_value
self.dead_zone = dead_zone
self.hot_zone = hot_zone
self.min_raw_value = float(min(min_raw_value, max_raw_value))
self.max_raw_value = float(max(min_raw_value, max_raw_value))
self.axis_event_code = axis_event_code
self.sname = sname
def _input_to_raw_value(self, value: int):
"""
Convert the value read from evdev to a -1.0 to 1.0 range.
:internal:
:param value:
a value ranging from the defined minimum to the defined maximum value.
:return:
-1.0 at minumum, 1.0 at maximum, linearly interpolating between those two points.
"""
return (float(value) - self.min_raw_value) * (2 / (self.max_raw_value - self.min_raw_value)) - 1.0
@property
def raw_value(self) -> float:
"""
Get an uncorrected value for this axis
:return: a float value, negative to the left or down, and ranging from -1.0 to 1.0
"""
return self.__value
@property
def value(self) -> float:
"""
Get a centre-compensated, scaled, value for the axis, taking any dead-zone into account. The value will
scale from 0.0 at the edge of the dead-zone to 1.0 (positive) or -1.0 (negative) at the extreme position of
the controller or the edge of the hot zone, if defined as other than 1.0. The axis will auto-calibrate for
maximum value, initially it will behave as if the highest possible value from the hardware is 0.9 in each
direction, and will expand this as higher values are observed. This is scaled by this function and should
always return 1.0 or -1.0 at the extreme ends of the axis.
:return: a float value, negative to the left or down and ranging from -1.0 to 1.0
"""
mapped_value = map_dual_axis(self.min, self.max, self.centre, self.dead_zone, self.hot_zone, self.__value)
if self.invert:
return -mapped_value
else:
return mapped_value
[docs]
def reset(self):
"""
Reset calibration (max, min and centre values) for this axis specifically. Not generally needed, you can just
call the reset method on the SixAxis instance.
:internal:
"""
self.centre = 0.0
self.max = 0.9
self.min = -0.9
[docs]
def receive_device_value(self, raw_value: int):
"""
Set a new value, called from within the joystick implementation class when parsing the event queue.
:param raw_value: the raw value from the joystick hardware
:internal:
"""
new_value = self._input_to_raw_value(raw_value)
self.__value = new_value
if new_value > self.max:
self.max = new_value
elif new_value < self.min:
self.min = new_value
def __str__(self) -> str:
return "CentredAxis name={}, sname={}, corrected_value={}".format(self.name, self.sname, self.value)