Compare commits

..

No commits in common. "main" and "v1.1.1" have entirely different histories.
main ... v1.1.1

17 changed files with 97 additions and 195 deletions

View File

@ -12,10 +12,9 @@ exclude =
__pycache__,
build,
dist,
.conda,
.venv,
venv,
docs/source/api
.conda
.venv
venv
# Enable specific plugins or options
# Example: Enabling flake8-docstrings

View File

@ -27,13 +27,6 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install -e .[dev]
if [ "${{ matrix.python-version }}" = "3.13" ]; then
python -m pip install cffconvert
fi
- name: Validate CITATION.cff
if: ${{ matrix.python-version == '3.13' }}
run: cffconvert --validate
- name: Lint code with flake8
run: flake8

View File

@ -9,7 +9,7 @@ permissions:
contents: write
jobs:
build:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -17,36 +17,21 @@ jobs:
uses: actions/setup-python@v3
with:
python-version: "3.x"
- name: Install package and dependencies
run: pip install .[doc_build]
- name: Install dependencies
run: pip install sphinx sphinx_rtd_theme sphinx-autodoc-typehints myst-parser
- name: Generate Class List
run: python ./docs/source/generate_class_list.py
run: |
pip install .
python ./docs/source/generate_class_list.py
- name: Build Docs
run: |
cp LICENSE docs/source/LICENSE.md
cd docs
sphinx-apidoc -o ./source/ ../src/ -M --no-toc
rm ./source/*.rst
make html
touch ./build/html/.nojekyll
mkdir -p ./build/html/_autogenerated
cp ./build/html/api/* ./build/html/_autogenerated/
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/build/html
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages
folder: docs/build/html

View File

@ -10,10 +10,6 @@ jobs:
name: Build and publish
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/${{ github.event.repository.name }}/
steps:
- uses: actions/checkout@v3

1
.gitignore vendored
View File

@ -70,7 +70,6 @@ instance/
# Sphinx documentation
docs/_build/
docs/source/api/
# Autogenerated documentation
docs/source/modules.md

View File

@ -1,16 +1,15 @@
cff-version: 1.2.0
message: "If you use this software, please cite it as below."
title: pyhoff
abstract: The pyhoff package allows easy accessing of Beckhoff and Wago terminals with python over ModBus TCP
authors:
- family-names: Kruse
given-names: Nicolas
orcid: "https://orcid.org/0000-0001-6758-2269"
version: v1.1.2
date-released: "2025-08-04"
identifiers:
- description: This is the collection of archived snapshots of all versions of pyhoff
type: doi
value: "10.5281/zenodo.16740202"
license: MIT
version: 1.1.0
#date-released: "2025-04-01"
#identifiers:
# - description: This is the collection of archived snapshots of all versions of My Research Software
# type: doi
# value: "10.5281/"
license: MIT License
repository-code: "https://github.com/Nonannet/pyhoff"

View File

@ -1,5 +1,6 @@
# Pyhoff
## Description
The pyhoff package allows you to read and write the most common
Beckhoff and WAGO bus terminals ("Busklemmen") using the Ethernet bus
coupler ("Busskoppler") BK9000, BK9050, BK9100, or WAGO 750_352
@ -54,62 +55,6 @@ bk.select(KL4002, 0).set_voltage(1, 4.2)
```
## Adding new terminals
The package comes with automatic generated code stubs for nearly all
terminals. These stubs are not tested with hardware but for most
digital IO terminals the code should be fully functional.
Such a stub looks like this:
```python
# From ./src/pyhoff/devices.py:
class KL2442(DigitalOutputTerminal):
"""
KL2442: 2-channel digital output, 24 V DC, 2 x 4 A/1 x 8 A
(Automatic generated stub)
"""
parameters = {'output_bit_width': 2, 'input_bit_width': 0}
```
For analog IO terminals the stubs are functional as well,
but they provide only a generic `read_channel_word` and
`read_normalized` function (for inputs) without scaling the
values to voltages, currents or temperatures. For better usability
they might be extended with functions. Based on the stub the
extension could look like this:
```python
from pyhoff.devices import KL3054 as KL3054_stub
class KL3054(KL3054_stub):
def read_current(self, channel: int) -> float:
return self.read_normalized(channel) * 16.0 + 4.0
```
Or for contributing to the pyhoff package, the existing stub
code can be updated like this:
```python
# From ./src/pyhoff/devices.py:
class KL3054(AnalogInputTerminal):
"""
KL3054: 4x analog input 4...20 mA 12 Bit single-ended
"""
# Input: 4 x 16 Bit Daten (optional 4x 8 Bit Control/Status)
parameters = {'input_word_width': 4}
def read_current(self, channel: int) -> float:
"""
Read the current value from a specific channel.
Args:
channel: The channel number to read from.
Returns:
The current value in mA.
"""
return self.read_normalized(channel) * 16.0 + 4.0
```
## Contributing
Other analog and digital IO terminals are easy to complement. Contributions are welcome!
Please open an issue or submit a pull request on GitHub.
@ -117,24 +62,32 @@ Please open an issue or submit a pull request on GitHub.
## Developer Guide
To get started with developing the `pyhoff` package, follow these steps:
1. First, clone the repository to your local machine using Git:
1. **Clone the Repository**
First, clone the repository to your local machine using Git:
```bash
git clone https://github.com/Nonannet/pyhoff.git
cd pyhoff
```
2. It is recommended to use a virtual environment:
2. **Set Up a Virtual Environment**
It is recommended to use a virtual environment to manage dependencies. You can create one using `venv`:
```bash
python -m venv .venv
source .venv/bin/activate # On Windows/Powershell use `.\venv\Scripts\Activate.ps1`
python -m venv venv
source venv/bin/activate # On Windows use `venv\Scripts\activate`
```
3. Install pyhoff from source plus the development dependencies:
3. **Install Dev Dependencies**
Install pyhoff from source plus the dependencies required for development using `pip`:
```bash
pip install -e .[dev]
```
4. Ensure that everything is set up correctly by running the tests:
4. **Run Tests**
Ensure that everything is set up correctly by running the tests:
```bash
pytest
```

View File

@ -26,8 +26,7 @@ exclude_patterns = []
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
# html_theme = 'alabaster'
html_theme = 'pydata_sphinx_theme'
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']
autodoc_inherit_docstrings = True
autoclass_content = 'both'

View File

@ -2,66 +2,42 @@ import importlib
import inspect
import fnmatch
from io import TextIOWrapper
import os
def write_classes(f: TextIOWrapper, patterns: list[str], module_name: str, title: str, description: str = '', exclude: list[str] = []) -> None:
"""Write the classes to the file."""
module = importlib.import_module(module_name)
classes = [
name for name, obj in inspect.getmembers(module, inspect.isclass)
if (obj.__module__ == module_name and
any(fnmatch.fnmatch(name, pat) for pat in patterns if pat not in exclude) and
(obj.__doc__ and ('(Automatic generated stub)' not in obj.__doc__ or ' digital ' in obj.__doc__)) and
obj.__doc__ and '(no I/O function)' not in obj.__doc__
)
obj.__doc__ and '(Automatic generated stub)' not in obj.__doc__)
]
"""Write the classes to the file."""
f.write(f'## {title}\n\n')
if description:
f.write(f'{description}\n\n')
write_dochtree(f, title, classes)
for cls in classes:
with open(f'docs/source/api/{cls}.md', 'w') as f2:
f2.write(f'# {module_name}.{cls}\n')
f2.write('```{eval-rst}\n')
f2.write(f'.. autoclass:: {module_name}.{cls}\n')
f2.write(' :members:\n')
f2.write(' :show-inheritance:\n')
f2.write(' :inherited-members:\n')
if title not in ['Base classes', 'Bus coupler']:
f2.write(' :exclude-members: select, parameters\n')
if 'bus terminals' in title:
f2.write(' :class-doc-from: class\n')
f2.write('```\n\n')
def write_dochtree(f: TextIOWrapper, title: str, items: list[str]):
f.write('```{toctree}\n')
f.write(':maxdepth: 1\n')
f.write(f':caption: {title}:\n')
# f.write(':hidden:\n')
for text in items:
if not text.startswith('_'):
f.write(f"{text}\n")
f.write('```{eval-rst}\n')
f.write(f'.. autoclass:: {module_name}.{cls}\n')
f.write(' :members:\n')
f.write(' :undoc-members:\n')
f.write(' :show-inheritance:\n')
f.write(' :inherited-members:\n')
if title != 'Base classes':
f.write(' :exclude-members: select\n')
f.write('```\n\n')
if __name__ == "__main__":
# Ensure the output directory exists
os.makedirs('docs/source/api', exist_ok=True)
with open('docs/source/api/index.md', 'w') as f:
f.write('# Classes and Modules\n\n')
with open('docs/source/modules.md', 'w') as f:
f.write('# Classes\n\n')
write_classes(f, ['BK*', 'WAGO_750_352'], 'pyhoff.devices', title='Bus coupler',
description='These classes are bus couplers and are used to connect the IO bus terminals to a Ethernet interface.')
write_classes(f, ['KL*'], 'pyhoff.devices', title='Beckhoff bus terminals')
write_classes(f, ['WAGO*'], 'pyhoff.devices', title='WAGO bus terminals', exclude=['WAGO_750_352'])
write_classes(f, ['*Terminal*'], 'pyhoff.devices', title='Generic bus terminals')
write_classes(f, ['*'], 'pyhoff', title='Base classes',
description='These classes are base classes for devices and are typically not used directly.')
write_classes(f, ['*'], 'pyhoff.modbus', title='Modbus',

View File

@ -1,8 +1,9 @@
```{toctree}
:maxdepth: 1
:hidden:
api/index
repo
:maxdepth: 2
:caption: Contents:
readme
modules
```
```{include} ../../README.md

2
docs/source/readme.md Normal file
View File

@ -0,0 +1,2 @@
```{include} ../../README.md
```

View File

@ -1,3 +0,0 @@
# Code repository
Code repository is on GitHub: [github.com/Nonannet/pyhoff](https://github.com/Nonannet/pyhoff).

View File

@ -1,6 +1,6 @@
[project]
name = "pyhoff"
version = "1.1.2"
version = "1.1.1"
authors = [
{ name="Nicolas Kruse", email="nicolas.kruse@nonan.net" },
]
@ -25,19 +25,10 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
pyhoff = ["py.typed"]
[project.optional-dependencies]
dev = [
"pytest", "flake8", "mypy"
]
doc_build = [
"sphinx",
"pydata_sphinx_theme",
"sphinx-autodoc-typehints",
"myst-parser"
]
[tool.mypy]
files = ["src"]

View File

@ -137,10 +137,9 @@ class AnalogInputTerminal(BusTerminal):
Args:
channel: The channel number (1 based index) to read from.
error_value: Value that is returned in case the modbus read command fails.
Returns:
The read word value or provided error_value if read failed.
The read word value.
Raises:
Exception: If the word offset or count is out of range.
@ -175,7 +174,6 @@ class AnalogOutputTerminal(BusTerminal):
Args:
channel: The channel number (1 based index) to read from.
error_value: Value that is returned in case the modbus read command fails.
Returns:
The read word value or provided error_value if read failed.
@ -227,10 +225,19 @@ class BusCoupler():
"""
Base class for ModBus TCP bus coupler
Args:
host: ip or hostname of the bus coupler
port: port of the modbus host
debug: outputs modbus debug information
timeout: timeout for waiting for the device response
watchdog: time in seconds after the device sets all outputs to
default state. A value of 0 deactivates the watchdog.
debug: If True, debug information is printed.
Attributes:
bus_terminals (list[BusTerminal]): A list of bus terminal classes according to the
bus_terminals: A list of bus terminal classes according to the
connected terminals.
modbus (SimpleModbusClient): The underlying modbus client used for the connection.
modbus: The underlying modbus client used for the connection.
"""
def __init__(self, host: str, port: int = 502, bus_terminals: Iterable[type[BusTerminal]] = [],
@ -241,8 +248,6 @@ class BusCoupler():
Args:
host: ip or hostname of the bus coupler
port: port of the modbus host
bus_terminals: list of bus terminal classes for the
connected terminals
debug: outputs modbus debug information
timeout: timeout for waiting for the device response
watchdog: time in seconds after the device sets all outputs to

View File

@ -224,7 +224,7 @@ class KL3054(AnalogInputTerminal):
channel: The channel number to read from.
Returns:
The current value in mA.
The current value.
"""
return self.read_normalized(channel) * 16.0 + 4.0
@ -244,7 +244,7 @@ class KL3042(AnalogInputTerminal):
channel: The channel number to read from.
Returns:
The current value in mA.
The current value.
"""
return self.read_normalized(channel) * 20.0
@ -311,7 +311,7 @@ class KL4002(AnalogOutputTerminal):
Args:
channel: The channel number to set.
value: The voltage value to set in V.
value: The voltage value to set.
Returns:
True if the write operation succeeded.
@ -348,7 +348,7 @@ class KL4132(AnalogOutputTerminal):
Args:
channel: The channel number to set.
value: The voltage value to set in V.
value: The voltage value to set.
Returns:
True if the write operation succeeded.
@ -369,7 +369,7 @@ class KL4004(AnalogOutputTerminal):
Args:
channel: The channel number to set.
value: The voltage value to set in V.
value: The voltage value to set.
Returns:
True if the write operation succeeded.

View File

@ -48,13 +48,20 @@ class SimpleModbusClient:
"""
A simple Modbus TCP client
Args:
host: hostname or IP address
port: server port
unit_id: ModBus id
timeout: socket timeout in seconds
debug: if True prints out transmitted and received bytes in hex
Attributes:
host (str): hostname or IP address
port (int): server port
unit_id (int): ModBus id
timeout (float): socket timeout in seconds
last_error (str): contains last error message or empty string if no error occurred
debug (bool): if True prints out transmitted and received bytes in hex
host: hostname or IP address
port: server port
unit_id: ModBus id
timeout: socket timeout in seconds
last_error: contains last error message or empty string if no error occurred
debug: if True prints out transmitted and received bytes in hex
"""

View File