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

Fix writing to serial (rs485) on windows os. #2191

Merged
merged 4 commits into from
May 31, 2024

Conversation

andrew-harness
Copy link
Contributor

The issue with the write method on Windows is that it's trying to use self.sync_serial.fileno(), which doesn't work on Windows because the pySerial library doesn't provide a valid file descriptor.

To fix this, you can modify the write method to directly write to the serial port without using asyncio's add_writer method.

Copy link
Collaborator

@janiversen janiversen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are missing ! the transport part have a 100% coverage and we would like to keep that.

@@ -55,8 +55,17 @@ def close(self, exc: Exception | None = None) -> None:
def write(self, data) -> None:
"""Write some data to the transport."""
self.intern_write_buffer.append(data)
if not self.poll_task:
if not self.poll_task and os.name != "nt":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if/elif is not very logical it seems you could simplify it a lot.

Secondly I am still not convinced this is the right way, at least please explain why CI that also runs on windows works.

Copy link
Contributor Author

@andrew-harness andrew-harness May 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if/elif is checking for the platform type. This could also be done with the platform library to be a bit more specific and check if it is Windows. Agreed could be simplified.

There also may be a more cross-platform way. I didn't spend much time on it as I just wanted to get it working for a few tests. This could be closed and just submitted as an issue for further investigation.

I'm not exactly sure why the CI is passing for windows but not working on real hardware. I would assume it is because the CI doesn't test real hardware but a NULLMODEM_HOST which may be behaving differently than hardware device. I did run the same test code (except port name) on Linux with no issues.

Here is the Exception that I get when running on Windows, Python 3.12.0:

Exception has occurred: UnsupportedOperation       (note: full exception trace is shown but execution is paused at: _run_module_as_main)
fileno
  File "C:\path-to-source\.venv\Lib\site-packages\pymodbus\transport\serialtransport.py", line 59, in write
    self.async_loop.add_writer(self.sync_serial.fileno(), self.intern_write_ready)
                               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\path-to-source\.venv\Lib\site-packages\pymodbus\transport\transport.py", line 393, in send
    self.transport.write(data)  # type: ignore[attr-defined]
    ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\path-to-source\.venv\Lib\site-packages\pymodbus\client\base.py", line 170, in async_execute
    self.send(packet)
  File "C:\path-to-source\icm.py", line 209, in read_register
    rr = await self.client.read_input_registers(register.value, 1, slave=self.slave_id)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\path-to-source\icm.py", line 268, in read_current_date
    return await self.read_register(self.InputRegisters.CURRENT_DATE)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\path-to-source\icm.py", line 347, in main
    icm450.current_date = await icm450.read_current_date()
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python312\Lib\asyncio\base_events.py", line 664, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "C:\Python312\Lib\asyncio\runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python312\Lib\asyncio\runners.py", line 194, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "C:\path-to-source\icm.py", line 371, in <module>
    asyncio.run(main())
  File "C:\Python312\Lib\runpy.py", line 88, in _run_code
    exec(code, run_globals)
  File "C:\Python312\Lib\runpy.py", line 198, in _run_module_as_main (Current frame)
    return _run_code(code, main_globals, None,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
io.UnsupportedOperation: fileno

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The high level tests uses null modem, which basically bypasses pyserial, but there are low level tests that uses pyserial (transport itself), however they do NOT use the serial interface but the socket interface, and that might be the difference.

Apologies if I come across as a bit negative, but the problem is that I have to maintain the code long term so while I look positive at (nearly) any PR, I give a feedback that ensures it is not making maintenance harder.

Precise comments:

  • if / elif, as you admitted it can be simplified, this is important because in a year from now I (or someone else) will start thinking what is the reason that it is complex, what have I overlooked...

  • Cross platform: this PR should not be closed, it solves a problem, we just need to get it right. A simplification would be to only use the create_task you suggest, and remove the add_writer totally....I am all in a favor of that.

So to sum up:

  • please simplify the if / elif
  • I suggest to remove add_writer in total and only use your create task.
  • Please add tests to ensure the coverage is 100% (pytest --cov or just check the CI run)

I look forward to review a new version from you...I hope you can see that I am positive and your PR have value !

Copy link
Contributor Author

@andrew-harness andrew-harness May 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this a bit more. It appears if it is windows it should set the poll_task which in the intern_write_ready does not use the fileno() to write.

In setup it is specifically setting the poll_task on windows.

    def setup(self) -> None:
        """Prepare to read/write."""
        if os.name == "nt" or self.force_poll:
            self.poll_task = asyncio.create_task(self.polling_task())
            self.poll_task.set_name("SerialTransport poll")
        else:
            self.async_loop.add_reader(self.sync_serial.fileno(), self.intern_read_ready)
        self.async_loop.call_soon(self.intern_protocol.connection_made, self)

It looks like what was happening was the write function was being called before the setup function is ran.

I put a break point in the write function at the add_writer line and noticed that it was being hit. Which according to the setup function it shouldn't be since it is Windows. I then added a break point to the setup function and noticed that the write function is called once before the setup function is ran.

Adding a timeout after the connect and before a read_input_registers (or other modbus operation) allowed the setup function to finish before the write operation. I then did not receive any exceptions.

Looking at the create_serial_connection you can see that the transport.setup is scheduled to run once the event loop becomes idle. This means that read and write operations can happen before the setup function is run.

Calling the setup function to run immediately mitigates this issue.

async def create_serial_connection(
    loop, protocol_factory, *args, **kwargs
) -> tuple[asyncio.Transport, asyncio.BaseProtocol]:
    """Create a connection to a new serial port instance."""
    protocol = protocol_factory()
    transport = SerialTransport(loop, protocol, *args, **kwargs)
    transport.setup()
    # loop.call_soon(transport.setup)
    return transport, protocol

I think my original changes should be rejected and this change be implemented instead or the connected function
(that is typically awaited after client creation) wait for the connection_made handler to finish, which is called from the setup function.

Thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds correct, please update the PR

@janiversen janiversen merged commit df0c09e into pymodbus-dev:dev May 31, 2024
1 check passed
janiversen added a commit that referenced this pull request Jun 18, 2024
Co-authored-by: jan iversen <jancasacondor@gmail.com>
janiversen added a commit that referenced this pull request Jun 27, 2024
* add _legacy_decoder to message rtu (#2119)

* Add generate_ssl() to TLS client as helper. (#2120)

* ASCII framer using message decode() (#2128)

* SOCKET/TLS framer using message decode(). (#2129)

* Fix decode for wrong mdap len.

* Streamline message class. (#2133)

* modbus_server: call execute in a way that those can be either coroutines or normal methods (#2139)

* Clean datastore setValues. (#2145)

* fixed kwargs not being expanded for actions on bit registers, adjusted tests to catch this issue (#2161)

* datastore: add async_setValues/getValues methods (#2165)

Co-authored-by: Ilkka Ollakka <ilkka.ollakka@cloudersolutions.com>

* Request/Response: change execute to be async method (#2142)

* Bump actions CI. (#2166)

* Fix usage of AsyncModbusTcpClient in client docs page (#2169)

* Sphinx: do not turn warnings into errors.

* Add minimal devcontainer. (#2172)

* Transaction id overrun.

* call async datastore from modbus server (#2144)

* Datastore will not return ExceptionResponse. (#2175)

* Describe zero_mode in ModbusSlaveContext.__init__ (#2187)

* Solve pylint error.

* Show error if example is run without support files. (#2189)

* Fix usage file names (#2194)

* Update client.rst (#2199)

* Transaction_id for serial == 0. (#2208)

* Remember to remove serial writer. (#2209)

* Fix writing to serial (rs485) on windows os. (#2191)

Co-authored-by: jan iversen <jancasacondor@gmail.com>

* test convert registers with 1234.... (#2217)

* Solve serial unrequested frame. (#2219)

* Log comm retries. (#2220)

* prepare v3.6.9.

* pylint.

* Remove python 3.8 from CI.

---------

Co-authored-by: Ilkka Ollakka <14361597+ilkka-ollakka@users.noreply.github.com>
Co-authored-by: sumguytho <48988983+sumguytho@users.noreply.github.com>
Co-authored-by: Ilkka Ollakka <ilkka.ollakka@cloudersolutions.com>
Co-authored-by: Yohrog <69543220+Yohrog@users.noreply.github.com>
Co-authored-by: James Cameron <90580716+jcameron-sso@users.noreply.github.com>
Co-authored-by: Qi Li <160646648+qili-eaton@users.noreply.github.com>
Co-authored-by: andrew-harness <75149647+andrew-harness@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants