Skip to content

Commit

Permalink
Add support for specifying port in Gen2 devices (#326)
Browse files Browse the repository at this point in the history
* Add support for specifying port

* Make CI mypy happy

* apply review comment

* add exception CustomPortNotSupported

---------

Co-authored-by: Simone Chemelli <[email protected]>
  • Loading branch information
GarrStau and chemelli74 authored Feb 28, 2024
1 parent fb26961 commit a393624
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 25 deletions.
2 changes: 1 addition & 1 deletion aioshelly/ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,6 @@ async def async_ensure_ble_enabled(device: RpcDevice) -> bool:
ble_enable = await device.ble_setconfig(enable=True, enable_rpc=True)
if not ble_enable["restart_required"]:
return False
LOGGER.info("BLE enabled, restarting device %s", device.ip_address)
LOGGER.info("BLE enabled, restarting device %s:%s", device.ip_address, device.port)
await device.trigger_reboot(3500)
return True
5 changes: 5 additions & 0 deletions aioshelly/block_device/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ..common import ConnectionOptions, IpOrOptionsType, get_info, process_ip_or_options
from ..const import CONNECT_ERRORS, DEVICE_IO_TIMEOUT, HTTP_CALL_TIMEOUT, MODEL_RGBW2
from ..exceptions import (
CustomPortNotSupported,
DeviceConnectionError,
FirmwareUnsupported,
InvalidAuthError,
Expand Down Expand Up @@ -119,6 +120,10 @@ async def initialize(self, async_init: bool = False) -> None:
if self._initializing:
raise RuntimeError("Already initializing")

# GEN1 cannot be configured behind a range extender as CoAP port cannot be natted
if self.options.port != 80:
raise CustomPortNotSupported

self._initializing = True
self.initialized = False
ip = self.options.ip_address
Expand Down
10 changes: 6 additions & 4 deletions aioshelly/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ConnectionOptions:
temperature_unit: str = "C"
auth: aiohttp.BasicAuth | None = None
device_mac: str | None = None
port: int = 80

def __post_init__(self) -> None:
"""Call after initialization."""
Expand Down Expand Up @@ -81,29 +82,30 @@ async def get_info(
aiohttp_session: aiohttp.ClientSession,
ip_address: str,
device_mac: str | None = None,
port: int = 80,
) -> dict[str, Any]:
"""Get info from device through REST call."""
try:
async with aiohttp_session.get(
URL.build(scheme="http", host=ip_address, path="/shelly"),
URL.build(scheme="http", host=ip_address, port=port, path="/shelly"),
raise_for_status=True,
timeout=DEVICE_IO_TIMEOUT,
) as resp:
result: dict[str, Any] = await resp.json()
except CONNECT_ERRORS as err:
error = DeviceConnectionError(err)
_LOGGER.debug("host %s: error: %r", ip_address, error)
_LOGGER.debug("host %s:%s: error: %r", ip_address, port, error)
raise error from err

mac = result["mac"]
if device_mac and device_mac != mac:
mac_err = MacAddressMismatchError(f"Input MAC: {device_mac}, Shelly MAC: {mac}")
_LOGGER.debug("host %s: error: %r", ip_address, mac_err)
_LOGGER.debug("host %s:%s: error: %r", ip_address, port, mac_err)
raise mac_err

if not shelly_supported_firmware(result):
fw_error = FirmwareUnsupported(result)
_LOGGER.debug("host %s: error: %r", ip_address, fw_error)
_LOGGER.debug("host %s:%s: error: %r", ip_address, port, fw_error)
raise fw_error

return result
Expand Down
4 changes: 4 additions & 0 deletions aioshelly/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ class MacAddressMismatchError(ShellyError):
"""Raised if input MAC address does not match the device MAC address."""


class CustomPortNotSupported(ShellyError):
"""Raise if GEN1 devices are access with custom port."""


class RpcCallError(ShellyError):
"""Raised to indicate errors in RPC call."""

Expand Down
21 changes: 16 additions & 5 deletions aioshelly/rpc_device/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ def __init__(
self._status: dict[str, Any] | None = None
self._event: dict[str, Any] | None = None
self._config: dict[str, Any] | None = None
self._wsrpc = WsRPC(options.ip_address, self._on_notification)
self._wsrpc = WsRPC(
options.ip_address, self._on_notification, port=options.port
)
sub_id = options.ip_address
if options.device_mac:
sub_id = options.device_mac
Expand Down Expand Up @@ -144,6 +146,11 @@ def ip_address(self) -> str:
"""Device ip address."""
return self.options.ip_address

@property
def port(self) -> int:
"""Device port."""
return self.options.port

async def initialize(self, async_init: bool = False) -> None:
"""Device initialization."""
if self._initializing:
Expand All @@ -152,9 +159,13 @@ async def initialize(self, async_init: bool = False) -> None:
self._initializing = True
self.initialized = False
ip = self.options.ip_address
port = self.options.port
try:
self._shelly = await get_info(
self.aiohttp_session, self.options.ip_address, self.options.device_mac
self.aiohttp_session,
self.options.ip_address,
self.options.device_mac,
self.options.port,
)

if self.requires_auth:
Expand All @@ -177,7 +188,7 @@ async def initialize(self, async_init: bool = False) -> None:
self.initialized = True
except InvalidAuthError as err:
self._last_error = InvalidAuthError(err)
_LOGGER.debug("host %s: error: %r", ip, self._last_error)
_LOGGER.debug("host %s:%s: error: %r", ip, port, self._last_error)
# Auth error during async init, used by sleeping devices
# Will raise 'invalidAuthError' on next property read
if not async_init:
Expand All @@ -186,13 +197,13 @@ async def initialize(self, async_init: bool = False) -> None:
self.initialized = True
except (MacAddressMismatchError, FirmwareUnsupported) as err:
self._last_error = err
_LOGGER.debug("host %s: error: %r", ip, err)
_LOGGER.debug("host %s:%s: error: %r", ip, port, err)
if not async_init:
await self._disconnect_websocket()
raise
except (*CONNECT_ERRORS, RpcCallError) as err:
self._last_error = DeviceConnectionError(err)
_LOGGER.debug("host %s: error: %r", ip, self._last_error)
_LOGGER.debug("host %s:%s: error: %r", ip, port, self._last_error)
if not async_init:
await self._disconnect_websocket()
raise DeviceConnectionError(err) from err
Expand Down
30 changes: 22 additions & 8 deletions aioshelly/rpc_device/wsrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,13 @@ def request_frame(self) -> dict[str, Any]:
class WsRPC:
"""WsRPC class."""

def __init__(self, ip_address: str, on_notification: Callable) -> None:
def __init__(
self, ip_address: str, on_notification: Callable, port: int = 80
) -> None:
"""Initialize WsRPC class."""
self._auth_data: AuthData | None = None
self._ip_address = ip_address
self._port = port
self._on_notification = on_notification
self._rx_task: tasks.Task[None] | None = None
self._client: ClientWebSocketResponse | None = None
Expand All @@ -178,7 +181,9 @@ async def connect(self, aiohttp_session: aiohttp.ClientSession) -> None:
_LOGGER.debug("Trying to connect to device at %s", self._ip_address)
try:
self._client = await aiohttp_session.ws_connect(
URL.build(scheme="http", host=self._ip_address, path="/rpc"),
URL.build(
scheme="http", host=self._ip_address, port=self._port, path="/rpc"
),
autoping=False,
)
except (
Expand All @@ -194,8 +199,9 @@ async def connect(self, aiohttp_session: aiohttp.ClientSession) -> None:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE)
except OSError as err:
_LOGGER.warning(
"%s: Failed to set socket receive buffer size: %s",
"%s:%s: Failed to set socket receive buffer size: %s",
self._ip_address,
self._port,
err,
)

Expand Down Expand Up @@ -255,8 +261,9 @@ async def _ping_if_not_closed(self) -> None:
def _pong_not_received(self) -> None:
"""Pong not received."""
_LOGGER.error(
"%s: Pong not received, device is likely unresponsive; disconnecting",
"%s:%s: Pong not received, device is likely unresponsive; disconnecting",
self._ip_address,
self._port,
)
self._create_and_track_task(self.disconnect())

Expand Down Expand Up @@ -340,10 +347,15 @@ async def _rx_msgs(self) -> None:
await self._client.pong(msg.data)
continue
frame = _receive_json_or_raise(msg)
_LOGGER.debug("recv(%s): %s", self._ip_address, frame)
_LOGGER.debug(
"recv(%s:%s): %s", self._ip_address, self._port, frame
)
except InvalidMessage as err:
_LOGGER.error(
"Invalid Message from host %s: %s", self._ip_address, err
"Invalid Message from host %s:%s: %s",
self._ip_address,
self._port,
err,
)
except ConnectionClosed:
break
Expand All @@ -355,7 +367,9 @@ async def _rx_msgs(self) -> None:
self.handle_frame(frame)
finally:
_LOGGER.debug(
"Websocket client connection from %s closed", self._ip_address
"Websocket client connection from %s:%s closed",
self._ip_address,
self._port,
)
self._cancel_heatbeat_and_pong_response_cb()
# Ensure the underlying transport is closed
Expand Down Expand Up @@ -439,7 +453,7 @@ async def _rpc_call(

async def _send_json(self, data: dict[str, Any]) -> None:
"""Send json frame to device."""
_LOGGER.debug("send(%s): %s", self._ip_address, data)
_LOGGER.debug("send(%s:%s): %s", self._ip_address, self._port, data)
assert self._client
await self._client.send_json(data, dumps=json_dumps)

Expand Down
9 changes: 6 additions & 3 deletions tools/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ async def create_device(
) -> Any:
"""Create a Gen1/Gen2/Gen3 device."""
if gen is None:
if info := await get_info(aiohttp_session, options.ip_address):
if info := await get_info(
aiohttp_session, options.ip_address, port=options.port
):
gen = info.get("gen", 1)
else:
raise ShellyError("Unknown Gen")
Expand Down Expand Up @@ -70,14 +72,15 @@ def device_updated(

def print_device(device: BlockDevice | RpcDevice) -> None:
"""Print device data."""
port = getattr(device, "port", 80)
if not device.initialized:
print()
print(f"** Device @ {device.ip_address} not initialized **")
print(f"** Device @ {device.ip_address}:{port} not initialized **")
print()
return

model_name = MODEL_NAMES.get(device.model) or f"Unknown ({device.model})"
print(f"** {device.name} - {model_name} @ {device.ip_address} **")
print(f"** {device.name} - {model_name} @ {device.ip_address}:{port} **")
print()

if device.gen in BLOCK_GENERATIONS:
Expand Down
26 changes: 22 additions & 4 deletions tools/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from aioshelly.common import ConnectionOptions
from aioshelly.const import WS_API_URL
from aioshelly.exceptions import (
CustomPortNotSupported,
DeviceConnectionError,
FirmwareUnsupported,
InvalidAuthError,
Expand All @@ -46,14 +47,19 @@ async def test_single(options: ConnectionOptions, init: bool, gen: int | None) -
print(f"Invalid or missing authorization, error: {repr(err)}")
return
except DeviceConnectionError as err:
print(f"Error connecting to {options.ip_address}, error: {repr(err)}")
print(
f"Error connecting to {options.ip_address}:{options.port}, error: {repr(err)}"
)
return
except MacAddressMismatchError as err:
print(f"MAC address mismatch, error: {repr(err)}")
return
except WrongShellyGen:
print(f"Wrong Shelly generation {gen}, device gen: {2 if gen==1 else 1}")
return
except CustomPortNotSupported:
print(f"Custom port ({options.port}) not supported for Gen1")
return

print_device(device)

Expand All @@ -65,6 +71,8 @@ async def test_single(options: ConnectionOptions, init: bool, gen: int | None) -

async def test_devices(init: bool, gen: int | None) -> None:
"""Test multiple devices."""
options: ConnectionOptions

device_options = []
with open("devices.json", encoding="utf8") as fp:
for line in fp:
Expand All @@ -86,7 +94,7 @@ async def test_devices(init: bool, gen: int | None) -> None:
continue

print()
print(f"Error printing device @ {options.ip_address}")
print(f"Error printing device @ {options.ip_address}:{options.port}")

if isinstance(result, FirmwareUnsupported):
print("Device firmware not supported")
Expand All @@ -113,6 +121,13 @@ def get_arguments() -> tuple[argparse.ArgumentParser, argparse.Namespace]:
parser.add_argument(
"--ip_address", "-ip", type=str, help="Test single device by IP address"
)
parser.add_argument(
"--device_port",
"-dp",
type=int,
default=80,
help="Port to use when testing single device",
)
parser.add_argument(
"--coap_port",
"-cp",
Expand Down Expand Up @@ -161,7 +176,6 @@ def get_arguments() -> tuple[argparse.ArgumentParser, argparse.Namespace]:
parser.add_argument(
"--mac", "-m", type=str, help="Optional device MAC to subscribe for updates"
)

parser.add_argument(
"--update_ws",
"-uw",
Expand Down Expand Up @@ -213,7 +227,11 @@ def handle_sigint(_exit_code: int, _frame: FrameType) -> None:
if args.username and args.password is None:
parser.error("--username and --password must be used together")
options = ConnectionOptions(
args.ip_address, args.username, args.password, device_mac=args.mac
args.ip_address,
args.username,
args.password,
device_mac=args.mac,
port=args.device_port,
)
if args.update_ws:
await update_outbound_ws(options, args.init, args.update_ws)
Expand Down

0 comments on commit a393624

Please sign in to comment.