Skip to content

Commit 9af8b6e

Browse files
authored
Merge pull request #2 from bleykauf/develop
Release v1.0.0
2 parents 498b23a + f9c5393 commit 9af8b6e

File tree

12 files changed

+963
-726
lines changed

12 files changed

+963
-726
lines changed

.github/workflows/test.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: tests # also sets the name of the tests badge, so do not change for now
2+
3+
on:
4+
push:
5+
6+
jobs:
7+
test:
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
os: [ubuntu-latest, macos-latest, windows-latest]
12+
python-version: ["3.8", "3.9", "3.10","3.11", "3.12"]
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Set up Python ${{ matrix.python-version }}
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: ${{ matrix.python-version }}
19+
cache: pip
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install .[tests]
24+
- name: Run pytest
25+
run: pytest .
26+
- name: Run mypy
27+
run: mypy --explicit-package-base .
28+
29+
coverage:
30+
needs:
31+
- test
32+
runs-on: ubuntu-latest
33+
34+
steps:
35+
- uses: actions/checkout@v4
36+
with:
37+
fetch-depth: 0
38+
- name: Set up Python
39+
uses: actions/setup-python@v4
40+
with:
41+
python-version: 3.11
42+
cache: pip
43+
- name: Install dependencies
44+
run: |
45+
python -m pip install --upgrade pip
46+
pip install . pytest pytest-cov
47+
- name: Create coverage report
48+
run: |
49+
coverage run -m pytest .
50+
coverage report -m
51+
- name: Create coverage badge
52+
uses: tj-actions/coverage-badge-py@v2
53+
with:
54+
output: docs/coverage.svg
55+
- name: Verify Changed files
56+
uses: tj-actions/verify-changed-files@v16
57+
id: verify-changed-files
58+
with:
59+
files: docs/coverage.svg
60+
- name: Commit files
61+
if: steps.verify-changed-files.outputs.files_changed == 'true'
62+
run: |
63+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
64+
git config --local user.name "github-actions[bot]"
65+
git add docs/coverage.svg
66+
git commit -m "Updated coverage.svg"
67+
- name: Push changes
68+
if: steps.verify-changed-files.outputs.files_changed == 'true'
69+
uses: ad-m/github-push-action@master
70+
with:
71+
github_token: ${{ secrets.github_token }}
72+
branch: ${{ github.ref }}

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
# MeerTEC -- Python implemenation of the MeCom interface for Meerstetter TECs.
2-
3-
<!---
4-
[![Conda](https://img.shields.io/conda/v/conda-forge/meer_tec?color=blue&label=conda-forge)](https://anaconda.org/conda-forge/meer_tec)
5-
[![Build Status](https://travis-ci.com/bleykauf/meer_tec.svg?branch=main)](https://travis-ci.com/bleykauf/meer_tec)
6-
[![Documentation Status](https://readthedocs.org/projects/meer_tec/badge/?version=latest)](https://meer_tec.readthedocs.io/en/latest/?badge=latest)
7-
[![Coverage Status](https://coveralls.io/repos/github/bleykauf/meer_tec/badge.svg?branch=main)](https://coveralls.io/github/bleykauf/meer_tec?branch=main)
2+
<!--
3+
![Test Coverage](https://raw.githubusercontent.com/bleykauf/meer_tec/master/docs/coverage.svg)
84
-->
95
[![PyPI](https://img.shields.io/pypi/v/meer_tec?color=blue)](https://pypi.org/project/meer_tec/)
6+
![Test Status](https://github.com/bleykauf/meer_tec/actions/workflows/test.yml/badge.svg)
7+
![Test Coverage](./docs/coverage.svg)
108
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
119

10+
1211
Both communication via USB and [Lantronix XPort](https://www.lantronix.com/products/xport/) are supported.
1312

1413
Note that not all commands of [MeCom](https://www.meerstetter.ch/customer-center/compendium/64-tec-controller-remote-control) are implemented at this time. Feel free to submit more commands via a Pull Request.
@@ -19,6 +18,8 @@ Note that not all commands of [MeCom](https://www.meerstetter.ch/customer-center
1918
#### USB
2019

2120
```python
21+
from meer_tec.interfaces import USB
22+
from meer_tec.tec import TEC
2223
usb = USB("COM3")
2324
tec = TEC(usb, 0)
2425
```
@@ -27,6 +28,8 @@ tec = TEC(usb, 0)
2728
Create a connection to the XPort and pass it as an argument to one of the TECs
2829

2930
```python
31+
from meer_tec.interfaces import USB
32+
from meer_tec.tec import TEC
3033
xp = XPort('192.168.1.123')
3134
tec3 = TEC(xp, 3)
3235
```

docs/coverage.svg

Lines changed: 21 additions & 0 deletions
Loading

meer_tec/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
from .meer_tec import TEC, USB, XPort # noqa: F401

meer_tec/interfaces.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import socket
2+
import time
3+
from typing import Protocol
4+
5+
import serial
6+
7+
from .mecom import Message
8+
9+
10+
class Interface(Protocol):
11+
def query(self, request: Message) -> Message:
12+
...
13+
14+
def clear(self) -> None:
15+
...
16+
17+
18+
class XPort(socket.socket):
19+
def __init__(self, ip: str, port: int = 10001) -> None:
20+
super().__init__(socket.AF_INET, socket.SOCK_STREAM)
21+
self.settimeout(0.2)
22+
self.ip = ip
23+
self.port = port
24+
super().connect((self.ip, self.port))
25+
26+
def query(self, request: Message) -> Message:
27+
self.send(request.encode("ascii"))
28+
time.sleep(0.01)
29+
response = self.recv(128).decode("ascii")
30+
return Message(response, value_type=request.value_type)
31+
32+
def clear(self) -> None:
33+
_ = self.recv(128)
34+
35+
36+
class USB(serial.Serial):
37+
def __init__(self, port: str, timeout: int = 1, baudrate: int = 57600) -> None:
38+
super().__init__(
39+
port, baudrate=baudrate, timeout=timeout, write_timeout=timeout
40+
)
41+
42+
def query(self, request: "Message") -> str:
43+
self.write(request.encode("ascii"))
44+
time.sleep(0.01)
45+
response = self.read(128).decode("ascii")
46+
return Message(response, value_type=request.value_type)

meer_tec/mecom.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import random
2+
import struct
3+
from typing import Generic, Literal, Optional, Type, TypeVar
4+
5+
from PyCRC.CRCCCITT import CRCCCITT as CRC
6+
7+
PARAM_CMDS = ["VS", "?VR"]
8+
FloatOrInt = TypeVar("FloatOrInt", float, int)
9+
ParamCmds = Literal["VS", "?VR"]
10+
11+
12+
def calc_checksum(string: str) -> str:
13+
"""Calculate CRC checksum."""
14+
return f"{CRC().calculate(string):04X}"
15+
16+
17+
def construct_param_cmd(
18+
device_addr: int,
19+
cmd: str,
20+
param_id: int,
21+
value_type: Type[FloatOrInt],
22+
param_inst: int = 1,
23+
value: Optional[FloatOrInt] = None,
24+
seq_num: Optional[int] = None,
25+
) -> str:
26+
"""
27+
Construct a MeCom command.
28+
29+
:param device_addr: Device address (0 .. 255). Broadcast Device Address (0) will
30+
send the command to all connected Meerstetter devices
31+
:param param_id: Parameter ID (0 .. 65535)
32+
:param value_type: Value type (int or float)
33+
:param param_inst: Parameter instance (0 .. 255). For most parameters the instance
34+
is used to address the channel on the device
35+
:param value: Value to set
36+
:param seq_num: Sequence number (0 .. 65535). If not given, a random number will be
37+
generated
38+
:return: MeCom command
39+
"""
40+
if seq_num is None:
41+
seq_num = random.randint(0, 65535)
42+
43+
if seq_num < 0 or seq_num > 65535:
44+
raise ValueError("seq_num must be between 0 and 65535")
45+
46+
if cmd not in PARAM_CMDS:
47+
raise ValueError(f"cmd must be one of {PARAM_CMDS}")
48+
49+
if device_addr < 0 or device_addr > 255:
50+
raise ValueError("device_addr must be between 0 and 255")
51+
52+
if cmd in ["VS", "?VR"] and param_id is None:
53+
raise ValueError("param_id must be given for VS and ?VR commands")
54+
55+
if cmd == "VS":
56+
if value is None:
57+
raise ValueError("value must be given for VS command")
58+
if value_type is float:
59+
# convert float to hex of length 8, remove the leading '0X' and capitalize
60+
val = hex(struct.unpack("<I", struct.pack("<f", value))[0])[2:].upper()
61+
elif value_type is int:
62+
# convert int to hex of length 8
63+
val = f"{value:08X}"
64+
elif cmd == "?VR":
65+
val = ""
66+
67+
cmd = f"#{device_addr:02X}{seq_num:04X}{cmd}{param_id:04X}{param_inst:02X}{val}"
68+
return f"{cmd}{calc_checksum(cmd)}\r"
69+
70+
71+
def construct_reset_cmd(device_addr: int, seq_num: Optional[int] = None) -> str:
72+
"""
73+
Construct a MeCom reset command.
74+
75+
:param device_addr: Device address (0 .. 255). Broadcast Device Address (0) will
76+
send the command to all connected Meerstetter devices
77+
:param seq_num: Sequence number (0 .. 65535). If not given, a random number will be
78+
generated
79+
:return: MeCom command
80+
"""
81+
if seq_num is None:
82+
seq_num = random.randint(0, 65535)
83+
84+
if seq_num < 0 or seq_num > 65535:
85+
raise ValueError("seq_num must be between 0 and 65535")
86+
87+
cmd = f"#{device_addr:02X}{seq_num:04X}RS"
88+
return f"{cmd}{calc_checksum(cmd)}\r"
89+
90+
91+
def verify_response(reponse: "Message", request: "Message") -> bool:
92+
"""
93+
Verify a MeCom response.
94+
95+
:param reponse: MeCom response
96+
:param request: MeCom request
97+
:return: True if response is valid, False otherwise
98+
"""
99+
checksum_correct = reponse.checksum == calc_checksum(reponse[0:-5])
100+
request_match = reponse.seq_num == request.seq_num
101+
return checksum_correct & request_match
102+
103+
104+
class Message(str, Generic[FloatOrInt]):
105+
value_type: Type[FloatOrInt]
106+
107+
def __new__(cls, response: str, value_type: Type[FloatOrInt]):
108+
return super().__new__(cls, response)
109+
110+
def __init__(self, response: str, value_type: Type[FloatOrInt]) -> None:
111+
self.value_type = value_type
112+
self.device_addr = int(self[1:3], 8)
113+
self.seq_num = int(self[3:7], 16)
114+
self.payload = self[7:-5]
115+
self.checksum = self[-5:-1]
116+
117+
@property
118+
def value(self) -> FloatOrInt:
119+
if self.value_type is int:
120+
return int(self.payload, 16)
121+
if self.value_type is float:
122+
return struct.unpack("!f", bytes.fromhex(self.payload))[0]
123+
else:
124+
raise ValueError("value_type must be int or float")

0 commit comments

Comments
 (0)