Skip to content
Open
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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ is required for this. See README for further information. [#190]
of in a separate queue. [#221, #237]
- The field objects now provide `datatype_class` and `datatype_unit`
instead of `measurement_type`
- `LuxtronikSocketInterface` (config interface / port 8889) is now
asyncio-native: all read/write methods are coroutines, the connection
is persistent with automatic reconnect-on-error and retry-with-backoff
instead of opening/closing a socket per call, and `connect()`/`close()`
(plus `async with` support) replace implicit connection handling. See
[#133](https://github.com/Bouni/python-luxtronik/issues/133).

### Removed

Expand Down
12 changes: 11 additions & 1 deletion luxtronik/cfi/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,14 @@
WAIT_TIME_AFTER_PARAMETER_WRITE: Final = 1

# The data from the config interface are transmitted in 32-bit chunks.
LUXTRONIK_CFI_REGISTER_BIT_SIZE: Final = 32
LUXTRONIK_CFI_REGISTER_BIT_SIZE: Final = 32

# Default timeout (in seconds) applied to connect/send/receive operations.
LUXTRONIK_DEFAULT_TIMEOUT: Final = 60.0

# Number of retries (on top of the initial attempt) for a transient
# connection/protocol error before a read/write operation gives up.
LUXTRONIK_MAX_RETRIES: Final = 4

# Delay (in seconds) between retries.
LUXTRONIK_RETRY_DELAY: Final = 1
271 changes: 165 additions & 106 deletions luxtronik/cfi/interface.py

Large diffs are not rendered by default.

27 changes: 17 additions & 10 deletions luxtronik/scripts/dump_cfi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,31 @@
Script to dump all available config interface values of the Luxtronik controller
"""

from luxtronik import Luxtronik, LUXTRONIK_DEFAULT_PORT
import asyncio

from luxtronik import LuxtronikSocketInterface, LUXTRONIK_DEFAULT_PORT
from luxtronik.scripts import create_default_args_parser, dump_fields


def dump_all(client):
dump_fields(client.parameters)
dump_fields(client.calculations)
dump_fields(client.visibilities)
def dump_all(data):
dump_fields(data.parameters)
dump_fields(data.calculations)
dump_fields(data.visibilities)

def dump_cfi():

async def dump_cfi_async():
parser = create_default_args_parser(
"Dumps all config interface values of the Luxtronik controller",
LUXTRONIK_DEFAULT_PORT
"Dumps all config interface values of the Luxtronik controller", LUXTRONIK_DEFAULT_PORT
)
args = parser.parse_args()
print(f"Dump CFI of {args.ip}:{args.port}")
client = Luxtronik(args.ip, args.port)
dump_all(client)
async with LuxtronikSocketInterface(args.ip, args.port) as client:
data = await client.read()
dump_all(data)


def dump_cfi():
asyncio.run(dump_cfi_async())


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ luxtronik = "luxtronik.__main__:main"
[project.optional-dependencies]
dev = [
"pytest",
"pytest-asyncio",
"autoflake",
"black",
"flake8-comprehensions",
Expand Down Expand Up @@ -78,3 +79,6 @@ disable = [

[tool.ruff]
line-length = 120

[tool.pytest.ini_options]
asyncio_mode = "auto"
2 changes: 1 addition & 1 deletion tests/fake/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
fake_calculation_value, # noqa: F401
fake_visibility_value, # noqa: F401
FakeSocket, # noqa: F401
fake_create_connection # noqa: F401
fake_open_connection # noqa: F401
)
from tests.fake.fake_update_screen import FakeScreen # noqa: F401
92 changes: 54 additions & 38 deletions tests/fake/fake_socket.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import socket
import asyncio
import struct

from luxtronik import Parameters, Calculations, Visibilities
Expand All @@ -17,9 +17,15 @@ def fake_visibility_value(i):


class FakeSocket:
"""
Fake persistent-connection state, shared by a FakeStreamReader/FakeStreamWriter
pair, standing in for the (reader, writer) tuple `asyncio.open_connection()`
would normally return.
"""

last_instance = None
prev_instance = None
create_connection_exception = None
open_connection_exception = None
force_recv_result = None

# These code are hard coded here in order to prevent
Expand All @@ -33,9 +39,8 @@ def __init__(self):
FakeSocket.prev_instance = FakeSocket.last_instance
FakeSocket.last_instance = self

self._connected = False
self._closing = False
self._buffer = b""
self._blocking = False

# Offer some more entries
self._num_paras = len(Parameters()._data) + 10
Expand All @@ -44,24 +49,20 @@ def __init__(self):

self.written_values = {}

def setblocking(self, blocking):
self._blocking = blocking

def connect(self):
assert not self._connected
self._connected = True

def close(self):
self._connected = False
self._closing = True

def sendall(self, data):
assert self._connected
def is_closing(self):
return self._closing

def write(self, data):
assert not self._closing

cnt = len(data) // 4
content = struct.unpack(">" + "i" * cnt, data)

# Next, we compute our response, which is saved in self._buffer.
# The client can read the response with self.recv()
# The client can read the response with self.readexactly()

if content[0] == FakeSocket.code_write_parameter:
# Client wants to write a parameters
Expand Down Expand Up @@ -124,37 +125,52 @@ def sendall(self, data):
for i in range(response_cnt):
self._buffer += struct.pack(">b", fake_visibility_value(i))

def recv(self, cnt, flag=0):
assert self._connected
async def readexactly(self, count):
assert not self._closing

if FakeSocket.force_recv_result is not None:
return FakeSocket.force_recv_result
data = FakeSocket.force_recv_result
if len(data) < count:
raise asyncio.IncompleteReadError(partial=data, expected=count)
return data[:count]

if (not self._blocking) and len(self._buffer) < cnt:
raise BlockingIOError("Not enough bytes in buffer.")
assert len(self._buffer) >= count

assert len(self._buffer) >= cnt
data = self._buffer[0:count]
self._buffer = self._buffer[count:]
return data

data = self._buffer[0:cnt]

if not (flag & socket.MSG_PEEK):
# Remove data from buffer
self._buffer = self._buffer[cnt:]
class FakeStreamWriter:
def __init__(self, conn):
self._conn = conn

def write(self, data):
self._conn.write(data)

async def drain(self):
pass

def close(self):
self._conn.close()

async def wait_closed(self):
pass

def is_closing(self):
return self._conn.is_closing()

return data

# --- Context manager support ---
def __enter__(self):
self.connect()
return self
class FakeStreamReader:
def __init__(self, conn):
self._conn = conn

def __exit__(self, exc_type, exc, tb):
self.close()
return False
async def readexactly(self, count):
return await self._conn.readexactly(count)


def fake_create_connection(info):
if FakeSocket.create_connection_exception is not None:
raise FakeSocket.create_connection_exception
else:
return FakeSocket()
async def fake_open_connection(host, port):
if FakeSocket.open_connection_exception is not None:
raise FakeSocket.open_connection_exception
conn = FakeSocket()
return FakeStreamReader(conn), FakeStreamWriter(conn)
Loading