Skip to content

Commit

Permalink
gatebox and shutterbox improvements (#156)
Browse files Browse the repository at this point in the history
* reporting gatebox position (if available)
* fix for open/close mode in gatebox
* foundation for reporting unified cover type across devices
* save initialization state data as last updated
* introduce enums that will allow to map to homeassistant cover types
* simplify tilts
* fix for plain gate
* cover test api response update, fix wrong seeding of light tests
  • Loading branch information
swistakm authored Mar 13, 2024
1 parent 15b755c commit c5bc2cf
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 45 deletions.
7 changes: 1 addition & 6 deletions blebox_uniapi/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,11 @@ def __init__(
self._firmware_version = firmware_version
self._hardware_version = hardware_version
self._api_version = level

self._model = config.get("model", type)

self._api = config.get("api", {})

self._features = self.create_features(config, info, extended_state)

self._config = config

self._update_last_data(None)
self._update_last_data(extended_state)

def create_features(
self, config: dict, info: dict, extended_state: Optional[dict]
Expand Down
3 changes: 2 additions & 1 deletion blebox_uniapi/box_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]:
],
},
20200831: {
"api_path": "/state",
"api_path": "/state/extended",
"extended_state_path": "/state/extended",
"api": {
"primary": lambda x=None: ("GET", "/s/p", None),
Expand All @@ -132,6 +132,7 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]:
"position",
{
"position": "gate/currentPos",
"gate_type": "gate/gateType",
},
"gatebox",
GateBoxB,
Expand Down
167 changes: 148 additions & 19 deletions blebox_uniapi/cover.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from enum import IntEnum
from enum import IntEnum, auto

import blebox_uniapi.error
from .error import MisconfiguredDevice
Expand All @@ -10,6 +10,14 @@


class BleboxCoverState(IntEnum):
"""BleboxCoverState defines possible states of cover devices.
Note that enumeration of states is partially shared between
different types of devices (shutterBox, gateController) but
not all states are possible for every type. For details of
states refer to blebox official API documentation.
"""

MOVING_DOWN = 0
MOVING_UP = 1
MANUALLY_STOPPED = 2
Expand All @@ -21,8 +29,68 @@ class BleboxCoverState(IntEnum):
SAFETY_STOP = 8


class ShutterBoxControlType(IntEnum):
"""ShutterBoxControlType defines shuterBox command semantics"""

SEGMENTED_SHUTTER = 1
NO_CALIBRATION = 2
TILT_SHUTTER = 3
WINDOW_OPENER = 4
MATERIAL_SHUTTER = 5
AWNING = 6
SCREEN = 7
CURTAIN = 8


class GateBoxControlType(IntEnum):
"""GateBoxControlType defines gateBox command semantics known as `openCloseMode`.
Control type affects mainly [o]pen, [c]lose, and [n]ext commands which
are wrappers around [p]rimary and [s]econdary outputs. The only exception
is OPEN_CLOSE (2) control type that also means that the gateBox lacks
stop action because typical stop output is wired to [c]lose/[s]econdary
command.
"""

STEP_BY_STEP = 0
ONLY_OPEN = 1
OPEN_CLOSE = 2


class GateBoxGateType(IntEnum):
"""GateBoxGateType defines possible gate/cover types reported by gateBox"""

SLIDING_DOOR = 0
GARAGE_DOOR = 1
OVER_DOOR = 2
DOOR = 3


class UnifiedCoverType(IntEnum):
"""UnifiedCoverType defines single "cover type" concept shared between different
devices.
Some device types have concept of control type/mode that affects how device
operates and how it is being used (e.g. control type in shutterBox/gateControler),
but others have these two concepts separated (e.g. open mode vs. gate type in
gateBox). This enum provides unified concept of controlled cover type that
can be infered from internal device information end exposed to library user.
"""
AWNING = auto()
BLIND = auto()
CURTAIN = auto()
DAMPER = auto()
DOOR = auto()
GARAGE = auto()
GATE = auto()
SHADE = auto()
SHUTTER = auto()
WINDOW = auto()


class Gate:
_control_type: int
_control_type: Optional[int]

def __init__(self, control_type: int):
self._control_type = control_type
Expand All @@ -41,6 +109,11 @@ def read_tilt(self, alias: str, raw_value: Any, product: "Box") -> int:
min_position = self.min_position
return product.expect_int(alias, raw, 100, min_position)

def read_cover_type(
self, alias: str, raw_value: Any, product: "Box"
) -> UnifiedCoverType:
return UnifiedCoverType.GATE

@property
def min_position(self) -> int:
return 0
Expand Down Expand Up @@ -69,18 +142,40 @@ def read_has_stop(self, alias: str, raw_value: Any, product: "Box") -> bool:


class Shutter(Gate):
_control_type: Optional[ShutterBoxControlType]

@property
def min_position(self) -> int:
return -1 # "unknown"

@property
def has_tilt(self) -> bool:
if self._control_type == 3:
return True
return False
return self._control_type == ShutterBoxControlType.TILT_SHUTTER

def read_cover_type(
self, alias: str, raw_value: Any, product: "Box"
) -> UnifiedCoverType:
if self._control_type == ShutterBoxControlType.SEGMENTED_SHUTTER:
return UnifiedCoverType.SHUTTER
if self._control_type == ShutterBoxControlType.NO_CALIBRATION:
return UnifiedCoverType.SHUTTER
if self._control_type == ShutterBoxControlType.TILT_SHUTTER:
return UnifiedCoverType.SHUTTER
if self._control_type == ShutterBoxControlType.WINDOW_OPENER:
return UnifiedCoverType.WINDOW
if self._control_type == ShutterBoxControlType.MATERIAL_SHUTTER:
return UnifiedCoverType.SHADE
if self._control_type == ShutterBoxControlType.AWNING:
return UnifiedCoverType.AWNING
if self._control_type == ShutterBoxControlType.SCREEN:
return UnifiedCoverType.SHADE
if self._control_type == ShutterBoxControlType.CURTAIN:
return UnifiedCoverType.CURTAIN


class GateBox(Gate):
_control_type: Optional[GateBoxControlType]

@property
def is_slider(self) -> bool:
return False
Expand Down Expand Up @@ -150,21 +245,41 @@ def read_state(self, alias: str, raw_value: Any, product: "Box") -> int:
return 4 # open (upper/right limit)

def read_desired(self, alias: str, raw_value: Any, product: "Box") -> Optional[int]:
return None
return raw_value("position")

def read_has_stop(self, alias: str, raw_value: Any, product: "Box") -> bool:
"""
"extraButtonType" field isn't available in responses
from "GET" posts to "/s/p" or "/s/s" so I just returned True
"""
return True
# note: if control type is unknown we assume it is not open/close
# and has the stop feature via secondary button command.
return self._control_type != GateBoxControlType.OPEN_CLOSE

def read_cover_type(
self, alias: str, raw_value: Any, product: "Box"
) -> Optional[UnifiedCoverType]:
if (gate_type := raw_value("gate_type")) is None:
return

if gate_type == GateBoxGateType.GARAGE_DOOR:
return UnifiedCoverType.GARAGE
if gate_type == GateBoxGateType.SLIDING_DOOR:
return UnifiedCoverType.GATE
return UnifiedCoverType.DOOR

@property
def close_command(self) -> str:
if self._control_type == GateBoxControlType.OPEN_CLOSE:
return "secondary"


GateT = TypeVar("GateT", bound=Gate)


# TODO: handle tilt
class Cover(Feature):
_desired: Optional[int]
_state: Optional[BleboxCoverState]
_has_stop: Optional[bool]
_cover_type: Optional[UnifiedCoverType]

def __init__(
self,
product: "Box",
Expand All @@ -174,14 +289,14 @@ def __init__(
subclass: Type[GateT],
extended_state: dict,
) -> None:
self._control_type = None
if extended_state not in [None, {}]:
self._control_type = extended_state.get("shutter", {}).get(
"controlType", {}
)
control_type = None
if extended_state and issubclass(subclass, Shutter):
control_type = extended_state.get("shutter", {}).get("controlType", None)
elif extended_state and issubclass(subclass, GateBoxB):
control_type = extended_state.get("gate", {}).get("openCloseMode", None)

self._device_class = dev_class
self._attributes: GateT = subclass(self._control_type)
self._attributes: GateT = subclass(control_type)
self._tilt_current = None
super().__init__(product, alias, methods)

Expand Down Expand Up @@ -215,6 +330,10 @@ def has_tilt(self) -> bool:
def has_stop(self) -> bool:
return self._has_stop

@property
def cover_type(self) -> Optional[UnifiedCoverType]:
return self._cover_type

async def async_open(self) -> None:
await self.async_api_command(self._attributes.open_command)

Expand All @@ -231,7 +350,7 @@ async def async_set_position(self, value: Any) -> None:
await self.async_api_command("position", value)

async def async_set_tilt_position(self, value: Any) -> None:
if self.has_tilt and self._control_type == 3:
if self.has_tilt:
await self.async_api_command("tilt", value)
else:
raise NotImplementedError
Expand All @@ -242,6 +361,14 @@ async def async_close_tilt(self, **kwargs: Any) -> None:
async def async_open_tilt(self, **kwargs: Any) -> None:
await self.async_api_command("tilt", 0)

def _read_cover_type(self) -> Optional[UnifiedCoverType]:
product = self._product
if not product.last_data:
return None

alias = self._alias
return self._attributes.read_cover_type(alias, self.raw_value, self._product)

def _read_desired(self) -> Any:
product = self._product
if not product.last_data:
Expand Down Expand Up @@ -276,5 +403,7 @@ def after_update(self) -> None:
self._desired = self._read_desired()
self._state = self._read_state()
self._has_stop = self._read_has_stop()
if self._control_type == 3 and self._attributes.has_tilt:
self._cover_type = self._read_cover_type()

if self._attributes.has_tilt:
self._tilt_current = self._read_tilt()
9 changes: 6 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,14 @@ async def allow_get_info(self, aioclient_mock, info=None):
json_get_expect(
aioclient_mock, f"http://{self.IP}:80/api/device/state", json=data
)
if hasattr(self, "DEVICE_EXTENDED_INFO"):
data = self.DEVICE_EXTENDED_INFO if info is None else info
if (
(hasattr(self, "DEVICE_EXTENDED_INFO")) and
(path := getattr(self, "DEVICE_EXTENDED_INFO_PATH"))
):
data = self.DEVICE_EXTENDED_INFO or info
json_get_expect(
aioclient_mock,
f"http://{self.IP}:80{self.DEVICE_EXTENDED_INFO_PATH}",
f"http://{self.IP}:80/{path.lstrip('/')}",
json=data,
)

Expand Down
26 changes: 23 additions & 3 deletions tests/test_cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ async def test_unkown_position(self, aioclient_mock):
class TestGateBoxB(CoverTest):
"""Tests for cover devices representing a BleBox gateBoxB subgroup."""

DEV_INFO_PATH = "state"
DEV_INFO_PATH = "state/extended"

DEVICE_INFO = json.loads(
"""
Expand Down Expand Up @@ -512,7 +512,17 @@ class TestGateBoxB(CoverTest):
STATE_DEFAULT = json.loads(
"""
{
"gate": {"currentPos": 0}
"gate": {
"currentPos": 0,
"openCloseMode": 0,
"gateType": 1,
"gatePulseTimeMs": 1500,
"gateOutputState": 0,
"extraButtonType": 1,
"extraButtonPulseTimeMs": 1500,
"extraButtonOutputState": 0,
"inputsType": 0
}
}
"""
)
Expand All @@ -524,7 +534,17 @@ class TestGateBoxB(CoverTest):
STATE_UNKNOWN = json.loads(
"""
{
"gate": {"currentPos": -1}
"gate": {
"currentPos": -1,
"openCloseMode": 0,
"gateType": 1,
"gatePulseTimeMs": 1500,
"gateOutputState": 0,
"extraButtonType": 1,
"extraButtonPulseTimeMs": 1500,
"extraButtonOutputState": 0,
"inputsType": 0
}
}
"""
)
Expand Down
Loading

0 comments on commit c5bc2cf

Please sign in to comment.