pm2cd 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pm2cd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .pm2cd_ctrl import PM2CD
2
+
3
+ __all__ = ["PM2CD"]
pm2cd/core/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from .communication_tcp import Communication
2
+ from .errors import CommunicationError, MotorControlError, ProtocolError
3
+ from .motor_status import MotorStatus
4
+ from .version_info import VersionInfo
5
+
6
+ __all__ = [
7
+ "Communication",
8
+ "CommunicationError",
9
+ "MotorControlError",
10
+ "ProtocolError",
11
+ "MotorStatus",
12
+ ]
@@ -0,0 +1,416 @@
1
+ import logging
2
+ import socket
3
+ import threading
4
+ from typing import Optional
5
+
6
+ from pm2cd.core.errors import CommunicationError
7
+
8
+ # TCP通信層。PM2CD 固有のコマンド解釈は行わず、送受信だけを担当する。
9
+ # 通信異常時は接続状態と受信バッファを破棄し、次回通信へ異常応答を持ち越さない。
10
+
11
+
12
+ class Communication:
13
+ """PMシリーズ用のTCP通信層。
14
+
15
+ TCPソケットの生成、接続、CR+LF単位の送受信、切断までをこのクラス内で
16
+ 完結させる。
17
+
18
+ 通常運転中に発生し得る通信失敗はログへ原因を記録し、``False`` または
19
+ ``None`` を返す。コンストラクタ引数の不正など、戻り値では表現できない
20
+ 呼び出し側の誤りは例外とする。
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ ip_address: str,
26
+ port: int = 7777,
27
+ timeout_sec: float = 3.0,
28
+ encoding: str = "ascii",
29
+ logger=None,
30
+ log_level: str = "INFO",
31
+ ):
32
+ """インスタンスを初期化する。
33
+
34
+ Args:
35
+ ip_address: PM2CD の IP アドレス。
36
+ port: PM2CD の TCP ポート番号。
37
+ timeout_sec: TCP 通信および待機処理のタイムアウト秒数。
38
+ encoding: encoding。
39
+ logger: 使用する logger。None の場合はこのモジュールで生成する。
40
+ log_level: logger を生成する場合のログレベル。
41
+
42
+ Raises:
43
+ TypeError: 引数の型が不正な場合。
44
+ ValueError: 引数の値が不正な場合。
45
+ """
46
+ if not logger:
47
+ logging.basicConfig(level=(log_level or "INFO").upper())
48
+ logger = logging.getLogger(__name__)
49
+
50
+ self._logger = logger
51
+
52
+ if not isinstance(ip_address, str):
53
+ raise TypeError("ip_addressはstrで指定してください")
54
+
55
+ if not ip_address:
56
+ raise ValueError("ip_addressは空にできません")
57
+
58
+ if not isinstance(port, int) or isinstance(port, bool):
59
+ raise TypeError("portはintで指定してください")
60
+
61
+ if port < 1 or port > 65535:
62
+ raise ValueError("portは1から65535の範囲で指定してください")
63
+
64
+ if not isinstance(timeout_sec, (int, float)) or isinstance(timeout_sec, bool):
65
+ raise TypeError("timeout_secは数値で指定してください")
66
+
67
+ if timeout_sec <= 0:
68
+ raise ValueError("timeout_secは0より大きい値を指定してください")
69
+
70
+ if not isinstance(encoding, str):
71
+ raise TypeError("encodingはstrで指定してください")
72
+
73
+ self._ip_address = ip_address
74
+ self._port = port
75
+ self._timeout_sec = timeout_sec
76
+ self._encoding = encoding
77
+
78
+ self._socket = None # type: Optional[socket.socket]
79
+ self._receive_buffer = bytearray()
80
+ self._delimiter = b"\r\n"
81
+ self._lock = threading.RLock()
82
+
83
+ @property
84
+ def ip_address(
85
+ self,
86
+ ):
87
+ """ip_address を返す。"""
88
+ return self._ip_address
89
+
90
+ @property
91
+ def port(
92
+ self,
93
+ ):
94
+ """port を返す。"""
95
+ return self._port
96
+
97
+ @property
98
+ def timeout_sec(
99
+ self,
100
+ ):
101
+ """timeout_sec を返す。"""
102
+ return self._timeout_sec
103
+
104
+ @property
105
+ def encoding(
106
+ self,
107
+ ):
108
+ """encoding を返す。"""
109
+ return self._encoding
110
+
111
+ def is_connected(
112
+ self,
113
+ ) -> bool:
114
+ """TCP 接続状態を取得する。
115
+
116
+ Returns:
117
+ 接続中の場合は True、それ以外は False。
118
+ """
119
+ if self._socket is None:
120
+ return False
121
+
122
+ return True
123
+
124
+ def connect(
125
+ self,
126
+ ) -> bool:
127
+ """TCP接続する。
128
+
129
+ Returns:
130
+ 接続済み、または接続に成功した場合は ``True``。
131
+ 接続に失敗した場合は ``False``。
132
+ """
133
+ self._logger.info(f"== connect()")
134
+
135
+ # 接続状態の変更を送受信と同じロックで直列化する。
136
+ with self._lock:
137
+ if self._socket is not None:
138
+ return True
139
+
140
+ sock = None # type: Optional[socket.socket]
141
+
142
+ try:
143
+ sock = socket.create_connection(
144
+ (self._ip_address, self._port), timeout=self._timeout_sec
145
+ )
146
+ sock.settimeout(self._timeout_sec)
147
+ except OSError as exc:
148
+ if sock is not None:
149
+ try:
150
+ sock.close()
151
+ except OSError as close_exc:
152
+ self._logger.error(
153
+ f"接続失敗後のTCPソケット破棄にも失敗しました: {close_exc}"
154
+ )
155
+
156
+ message = (
157
+ f"TCP接続に失敗しました: {self._ip_address}:{self._port}: {exc}"
158
+ )
159
+ self._logger.error(message)
160
+ return False
161
+
162
+ # 新しい TCP セッションへ以前の受信断片を持ち越さない。
163
+ self._socket = sock
164
+ self._receive_buffer.clear()
165
+
166
+ self._logger.info(f"TCP接続しました: {self._ip_address}:{self._port}")
167
+ return True
168
+
169
+ def disconnect(
170
+ self,
171
+ ) -> bool:
172
+ """TCP接続を切断する。"""
173
+ self._logger.info(f"== disconnect()")
174
+
175
+ with self._lock:
176
+ # 切断処理中に別経路から古いソケットを使用しないよう、先に内部状態を切り離す。
177
+ sock = self._socket
178
+ self._socket = None
179
+ self._receive_buffer.clear()
180
+
181
+ if sock is None:
182
+ return True
183
+
184
+ try:
185
+ sock.shutdown(socket.SHUT_RDWR)
186
+ except OSError:
187
+ pass
188
+
189
+ try:
190
+ sock.close()
191
+ except OSError as exc:
192
+ self._logger.error(f"TCP切断に失敗しました: {exc}")
193
+ return False
194
+
195
+ self._logger.info(f"TCP切断しました")
196
+ return True
197
+
198
+ def send(
199
+ self,
200
+ command: str,
201
+ ) -> bool:
202
+ """CR+LFを付加してコマンドを送信する。"""
203
+ self._logger.info(f"== send(): {command!r}")
204
+
205
+ if not isinstance(command, str):
206
+ raise TypeError("commandはstrで指定してください")
207
+
208
+ if "\r" in command or "\n" in command:
209
+ raise ValueError("commandに改行文字を含めることはできません")
210
+
211
+ with self._lock:
212
+ return self._send(command)
213
+
214
+ def receive(
215
+ self,
216
+ ) -> Optional[str]:
217
+ """CR+LF単位で装置応答を1件受信する。"""
218
+ self._logger.info(f"== receive()")
219
+
220
+ with self._lock:
221
+ return self._receive()
222
+
223
+ def query(
224
+ self,
225
+ command: str,
226
+ ) -> Optional[str]:
227
+ """コマンドを送信し、その応答を1件受信する。"""
228
+ self._logger.debug(f"== query(): {command!r}")
229
+
230
+ if not isinstance(command, str):
231
+ raise TypeError("commandはstrで指定してください")
232
+
233
+ if "\r" in command or "\n" in command:
234
+ raise ValueError("commandに改行文字を含めることはできません")
235
+
236
+ with self._lock:
237
+ # query は同一ロック内で send -> receive を完結させ、別コマンドの応答が
238
+ # 間へ入り込まないようにする。
239
+ if not self._send(command):
240
+ return None
241
+
242
+ return self._receive()
243
+
244
+ def close(
245
+ self,
246
+ ) -> bool:
247
+ """通信を終了する。"""
248
+ return self.disconnect()
249
+
250
+ def __enter__(
251
+ self,
252
+ ) -> "Communication":
253
+ """context manager 開始時に接続を確立する。
254
+
255
+ Returns:
256
+ 'Communication' 型の結果。
257
+
258
+ Raises:
259
+ CommunicationError: PM2CD への接続を確立できない場合。
260
+ """
261
+ if not self.connect():
262
+ raise CommunicationError(
263
+ f"TCP接続を開始できません: {self._ip_address}:{self._port}"
264
+ )
265
+
266
+ return self
267
+
268
+ def __exit__(
269
+ self,
270
+ exc_type,
271
+ exc_value,
272
+ traceback,
273
+ ) -> None:
274
+ """context manager 終了時に接続を切断する。
275
+
276
+ Args:
277
+ exc_type: with ブロック内で発生した例外の型。
278
+ exc_value: with ブロック内で発生した例外。
279
+ traceback: with ブロック内で発生した例外の traceback。
280
+
281
+ Returns:
282
+ None。
283
+ """
284
+ self.disconnect()
285
+
286
+ def _send(
287
+ self,
288
+ command: str,
289
+ ) -> bool:
290
+ """ロック取得済みの状態でコマンドを送信する。"""
291
+ sock = self._require_socket("送信")
292
+ if sock is None:
293
+ return False
294
+
295
+ try:
296
+ data = command.encode(self._encoding) + self._delimiter
297
+ except (UnicodeEncodeError, LookupError) as exc:
298
+ self._logger.error(
299
+ f"コマンドのエンコードに失敗しました: {command!r}: {exc}"
300
+ )
301
+ return False
302
+
303
+ try:
304
+ sock.sendall(data)
305
+ except (OSError, socket.timeout) as exc:
306
+ self._logger.error(f"コマンド送信に失敗しました: {command!r}: {exc}")
307
+ self._close_after_error()
308
+ return False
309
+
310
+ return True
311
+
312
+ def _receive(
313
+ self,
314
+ ) -> Optional[str]:
315
+ """ロック取得済みの状態で応答を1件受信する。"""
316
+ while True:
317
+ # 受信済みバッファからCR+LF終端の1応答だけを切り出す。
318
+ delimiter_index = self._receive_buffer.find(self._delimiter)
319
+
320
+ if delimiter_index >= 0:
321
+ raw = bytes(self._receive_buffer[:delimiter_index])
322
+ del self._receive_buffer[: delimiter_index + len(self._delimiter)]
323
+
324
+ try:
325
+ response = raw.decode(self._encoding)
326
+ except (UnicodeDecodeError, LookupError) as exc:
327
+ self._logger.error(f"装置応答のデコードに失敗しました: {exc}")
328
+ self._close_after_error()
329
+ return None
330
+
331
+ return response
332
+
333
+ sock = self._require_socket("受信")
334
+ if sock is None:
335
+ return None
336
+
337
+ try:
338
+ chunk = sock.recv(4096)
339
+ except socket.timeout as exc:
340
+ self._logger.error(f"装置応答の受信がタイムアウトしました: {exc}")
341
+
342
+ # タイムアウト後に遅延応答が到着すると、次のqueryが古い応答を
343
+ # 誤って受信する可能性があるため、接続を破棄して同期を取り直す。
344
+ self._close_after_error()
345
+ return None
346
+ except OSError as exc:
347
+ self._logger.error(f"装置応答の受信に失敗しました: {exc}")
348
+ self._close_after_error()
349
+ return None
350
+
351
+ if not chunk:
352
+ self._logger.error(f"装置側からTCP接続が切断されました")
353
+ self._close_after_error()
354
+ return None
355
+
356
+ self._receive_buffer.extend(chunk)
357
+
358
+ def _require_socket(
359
+ self,
360
+ operation: str,
361
+ ) -> Optional[socket.socket]:
362
+ """接続済みソケットを返す。"""
363
+ if self._socket is None:
364
+ self._logger.error(f"TCP接続されていないため{operation}できません")
365
+ return None
366
+
367
+ return self._socket
368
+
369
+ def _close_after_error(
370
+ self,
371
+ ) -> None:
372
+ """通信異常後にソケットと受信バッファを破棄する。"""
373
+ # エラー発生後はソケット参照を先に無効化し、再利用を防止する。
374
+ sock = self._socket
375
+ self._socket = None
376
+ self._receive_buffer.clear()
377
+
378
+ if sock is None:
379
+ return
380
+
381
+ try:
382
+ sock.close()
383
+ except OSError as exc:
384
+ self._logger.error(f"通信異常後のTCPソケット破棄に失敗しました: {exc}")
385
+
386
+
387
+ def main() -> None:
388
+ """Communication の基本的な送受信例を実行する。
389
+
390
+ 実機の IP アドレスに変更してから、パッケージのモジュールとして実行する。
391
+ ``VER?`` を送信し、CR+LF で区切られた装置応答を 1 件受信する。
392
+
393
+ Returns:
394
+ None。
395
+ """
396
+ ip_address = "10.170.10.62"
397
+ port = 7777
398
+
399
+ communication = Communication(ip_address=ip_address, port=port)
400
+
401
+ if not communication.connect():
402
+ print("PM2CD へ接続できませんでした")
403
+ return
404
+
405
+ try:
406
+ response = communication.query("VER?")
407
+ if response is None:
408
+ print("VER? の取得に失敗しました")
409
+ else:
410
+ print(f"VER?: {response}")
411
+ finally:
412
+ communication.disconnect()
413
+
414
+
415
+ if __name__ == "__main__":
416
+ main()
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class ControllerStatus:
7
+ """``STQ?`` の REMOTE/LOCAL と停止可能数を保持する。"""
8
+
9
+ raw: str
10
+ remote_local: str
11
+ stopped_motor_count: int
12
+ is_remote: bool
13
+ can_start: bool
14
+
15
+
16
+ def parse_controller_status(raw: str) -> Optional[ControllerStatus]:
17
+ """``STQ?`` 応答を解析する。"""
18
+ if not isinstance(raw, str):
19
+ return None
20
+
21
+ text = raw.strip().upper()
22
+ if len(text) != 2 or text[0] not in ('R', 'L') or not text[1].isdigit():
23
+ return None
24
+
25
+ count = int(text[1])
26
+ if count < 0 or count > 4:
27
+ return None
28
+
29
+ return ControllerStatus(
30
+ raw=text,
31
+ remote_local=text[0],
32
+ stopped_motor_count=count,
33
+ is_remote=text[0] == 'R',
34
+ can_start=count > 0,
35
+ )
pm2cd/core/data.py ADDED
@@ -0,0 +1,32 @@
1
+ """PM2CD / PM2C-06A 固有データ。"""
2
+
3
+ # PM4C-06A series manual TABLE 2 RATE DATA (No. 000-025)
4
+ # Unit: ms / 1000 PPS
5
+ RATE_TABLE = [
6
+ 1000.0,
7
+ 800.0,
8
+ 600.0,
9
+ 500.0,
10
+ 400.0,
11
+ 300.0,
12
+ 200.0,
13
+ 150.0,
14
+ 125.0,
15
+ 100.0,
16
+ 75.0,
17
+ 50.0,
18
+ 30.0,
19
+ 20.0,
20
+ 15.0,
21
+ 10.0,
22
+ 7.5,
23
+ 5.0,
24
+ 4.0,
25
+ 2.0,
26
+ 1.5,
27
+ 1.0,
28
+ 0.5,
29
+ 0.3,
30
+ 0.2,
31
+ 0.1,
32
+ ]
pm2cd/core/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ class MotorControlError(Exception):
2
+ """モーター制御に関する基底例外。"""
3
+
4
+
5
+ class CommunicationError(MotorControlError):
6
+ """通信処理に失敗した場合の例外。"""
7
+
8
+
9
+ class ProtocolError(MotorControlError):
10
+ """装置応答を解釈できない場合の例外。"""
@@ -0,0 +1,31 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class HomeStatus:
7
+ """``SETHP?x`` の ``0XYZ`` 応答を保持する。"""
8
+
9
+ raw: str
10
+ found: bool
11
+ found_direction: str
12
+ auto_start_direction: str
13
+
14
+
15
+ def parse_home_status(raw: str) -> Optional[HomeStatus]:
16
+ """``SETHP?x`` 応答を解析する。"""
17
+ if not isinstance(raw, str):
18
+ return None
19
+
20
+ text = raw.strip()
21
+ if len(text) != 4 or text[0] != '0':
22
+ return None
23
+ if any(value not in ('0', '1') for value in text[1:]):
24
+ return None
25
+
26
+ return HomeStatus(
27
+ raw=text,
28
+ found=text[1] == '1',
29
+ found_direction='cw' if text[2] == '0' else 'ccw',
30
+ auto_start_direction='cw' if text[3] == '0' else 'ccw',
31
+ )
@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class LimitStatus:
7
+ """``HDSTLS?`` 応答中の1軸分のリミット状態を保持する。"""
8
+
9
+ motor: int
10
+ channel: int
11
+ raw: str
12
+ hard_status: int
13
+ soft_status: int
14
+ hard_cw: bool
15
+ hard_ccw: bool
16
+ hard_hp: bool
17
+ soft_cw: bool
18
+ soft_ccw: bool
19
+
20
+ @property
21
+ def hard(self) -> List[bool]:
22
+ """ハードウェアLS現在状態を ``[CW, CCW, HP]`` で返す。"""
23
+ return [self.hard_cw, self.hard_ccw, self.hard_hp]
24
+
25
+ @property
26
+ def soft(self) -> List[bool]:
27
+ """ソフトウェアLS現在状態を ``[CW, CCW]`` で返す。"""
28
+ return [self.soft_cw, self.soft_ccw]
29
+
30
+
31
+ def parse_limit_status_all(raw: str) -> Optional[List[LimitStatus]]:
32
+ """PM2CD の ``HDSTLS?`` 2ch応答 ``abHHSS`` を解析する。"""
33
+ if not isinstance(raw, str):
34
+ return None
35
+
36
+ text = raw.strip()
37
+ if len(text) != 6:
38
+ return None
39
+
40
+ try:
41
+ channels = [int(value, 16) for value in text[0:2]]
42
+ hard_values = [int(value, 16) for value in text[2:4]]
43
+ soft_values = [int(value, 16) for value in text[4:6]]
44
+ except ValueError:
45
+ return None
46
+
47
+ values = []
48
+ for index in range(2):
49
+ hard_status = hard_values[index]
50
+ soft_status = soft_values[index]
51
+ if hard_status & ~0x07:
52
+ return None
53
+ if soft_status & ~0x03:
54
+ return None
55
+
56
+ values.append(
57
+ LimitStatus(
58
+ motor=index,
59
+ channel=channels[index],
60
+ raw=text,
61
+ hard_status=hard_status,
62
+ soft_status=soft_status,
63
+ hard_cw=bool(hard_status & 0x01),
64
+ hard_ccw=bool(hard_status & 0x02),
65
+ hard_hp=bool(hard_status & 0x04),
66
+ soft_cw=bool(soft_status & 0x01),
67
+ soft_ccw=bool(soft_status & 0x02),
68
+ )
69
+ )
70
+
71
+ return values