owlsensor 0.1__tar.gz → 0.2__tar.gz
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.
- {owlsensor-0.1 → owlsensor-0.2}/PKG-INFO +2 -2
- owlsensor-0.2/owlsensor/const.py +9 -0
- owlsensor-0.2/owlsensor/serial_cm.py +193 -0
- {owlsensor-0.1 → owlsensor-0.2}/owlsensor.egg-info/PKG-INFO +2 -2
- {owlsensor-0.1 → owlsensor-0.2}/owlsensor.egg-info/SOURCES.txt +1 -0
- owlsensor-0.2/owlsensor.egg-info/requires.txt +1 -0
- {owlsensor-0.1 → owlsensor-0.2}/setup.py +2 -2
- owlsensor-0.1/owlsensor/serial_cm.py +0 -191
- owlsensor-0.1/owlsensor.egg-info/requires.txt +0 -1
- {owlsensor-0.1 → owlsensor-0.2}/LICENSE +0 -0
- {owlsensor-0.1 → owlsensor-0.2}/README.rst +0 -0
- {owlsensor-0.1 → owlsensor-0.2}/owlsensor/__init__.py +0 -0
- {owlsensor-0.1 → owlsensor-0.2}/owlsensor.egg-info/dependency_links.txt +0 -0
- {owlsensor-0.1 → owlsensor-0.2}/owlsensor.egg-info/not-zip-safe +0 -0
- {owlsensor-0.1 → owlsensor-0.2}/owlsensor.egg-info/top_level.txt +0 -0
- {owlsensor-0.1 → owlsensor-0.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: owlsensor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2
|
|
4
4
|
Summary: Library to read data from OWL Energy meters
|
|
5
5
|
Home-page: https://github.com/PBrunot/owlsensor
|
|
6
6
|
Author: Pascal Brunot
|
|
@@ -16,6 +16,6 @@ Classifier: Programming Language :: Python :: 3.4
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.5
|
|
17
17
|
Description-Content-Type: text/x-rst
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: pyserial>=
|
|
19
|
+
Requires-Dist: pyserial-asyncio-fast>=0.14
|
|
20
20
|
|
|
21
21
|
This package is designed for integrating into Home Assistant a serial-connected OWL energy meter.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reading data from particulate matter sensors with a serial interface.
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
import asyncio
|
|
7
|
+
import serial_asyncio_fast
|
|
8
|
+
|
|
9
|
+
from .const import CONTINUE_REQUEST, ID_WAIT_HISTORY,ID_REPLY, PACKET_ID_HISTORY, PACKET_ID_HISTORY_DATA, PACKET_ID_REALTIME, START_REQUEST
|
|
10
|
+
|
|
11
|
+
STARTBLOCK = "SB"
|
|
12
|
+
RECORD_LENGTH = "RL"
|
|
13
|
+
# Ofsets of the PM data (always 2 byte)
|
|
14
|
+
CURRENT = "Current"
|
|
15
|
+
BAUD_RATE = "BAUD"
|
|
16
|
+
BYTE_ORDER = "BO",
|
|
17
|
+
LSB = "lsb"
|
|
18
|
+
MSB = "msb"
|
|
19
|
+
DTR_ON = "DTR"
|
|
20
|
+
DTR_OFF = "NOT_DTR"
|
|
21
|
+
MULTIPLIER = "MP"
|
|
22
|
+
TIMEOUT = "TO"
|
|
23
|
+
|
|
24
|
+
# Owl CM160 settings
|
|
25
|
+
OWL_CM160 = {
|
|
26
|
+
"TheOWL": "CM160",
|
|
27
|
+
STARTBLOCK: bytes([0x42, 0x4d, 0x00, 0x14]),
|
|
28
|
+
RECORD_LENGTH: 11,
|
|
29
|
+
CURRENT: 8,
|
|
30
|
+
BAUD_RATE: 250000,
|
|
31
|
+
BYTE_ORDER: LSB,
|
|
32
|
+
MULTIPLIER: 0.07,
|
|
33
|
+
TIMEOUT: 30
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SUPPORTED_SENSORS = {
|
|
37
|
+
"TheOWL,CM160": OWL_CM160
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
DEVICE_STATES = {
|
|
41
|
+
"Unknown": 0,
|
|
42
|
+
"IdentifierReceived": 1,
|
|
43
|
+
"TransmittingHistory": 2,
|
|
44
|
+
"TransmittingRealtime": 3
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
CMVALS=[CURRENT]
|
|
48
|
+
|
|
49
|
+
LOGGER = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CMDataCollector():
|
|
53
|
+
"""Controls the serial interface and reads data from the sensor."""
|
|
54
|
+
|
|
55
|
+
# pylint: disable=too-many-instance-attributes
|
|
56
|
+
def __init__(self,
|
|
57
|
+
serialdevice,
|
|
58
|
+
configuration,
|
|
59
|
+
scan_interval=0):
|
|
60
|
+
"""Initialize the data collector based on the given parameters."""
|
|
61
|
+
|
|
62
|
+
self.record_length = configuration[RECORD_LENGTH]
|
|
63
|
+
self.byte_order = configuration[BYTE_ORDER]
|
|
64
|
+
self.multiplier = configuration[MULTIPLIER]
|
|
65
|
+
self.timeout = configuration[TIMEOUT]
|
|
66
|
+
self.scan_interval = scan_interval
|
|
67
|
+
self.listeners = []
|
|
68
|
+
self.sensordata = {}
|
|
69
|
+
self.config = configuration
|
|
70
|
+
self._data = None
|
|
71
|
+
self.last_poll = None
|
|
72
|
+
self.device_state = DEVICE_STATES["Unknown"]
|
|
73
|
+
self.device_found = False
|
|
74
|
+
self.serialdevice = serialdevice
|
|
75
|
+
self.reader = None
|
|
76
|
+
self.writer = None
|
|
77
|
+
self.baudrate = configuration[BAUD_RATE]
|
|
78
|
+
|
|
79
|
+
async def connect(self):
|
|
80
|
+
"""Establish the serial connection asynchronously."""
|
|
81
|
+
self.reader, self.writer = await serial_asyncio_fast.open_serial_connection(
|
|
82
|
+
url=self.serialdevice,
|
|
83
|
+
baudrate=self.baudrate
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if self.scan_interval > 0:
|
|
87
|
+
asyncio.create_task(self.refresh())
|
|
88
|
+
|
|
89
|
+
async def refresh(self):
|
|
90
|
+
"""Asynchronous background refreshing task."""
|
|
91
|
+
while True:
|
|
92
|
+
await self.read_data()
|
|
93
|
+
await asyncio.sleep(self.scan_interval)
|
|
94
|
+
|
|
95
|
+
async def send_data(self, data: bytes):
|
|
96
|
+
LOGGER.debug("-> %s", ''.join(format(x, '02x') for x in data))
|
|
97
|
+
self.writer.write(data)
|
|
98
|
+
await self.writer.drain()
|
|
99
|
+
|
|
100
|
+
async def get_packet(self):
|
|
101
|
+
sbuf = bytearray()
|
|
102
|
+
starttime = asyncio.get_event_loop().time()
|
|
103
|
+
|
|
104
|
+
while len(sbuf) != self.record_length:
|
|
105
|
+
elapsed = asyncio.get_event_loop().time() - starttime
|
|
106
|
+
if elapsed > self.timeout:
|
|
107
|
+
LOGGER.error("Timeout waiting for data")
|
|
108
|
+
return bytearray()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
sbuf += await self.reader.readexactly(1)
|
|
112
|
+
except asyncio.IncompleteReadError:
|
|
113
|
+
LOGGER.warning("Timeout on data on serial")
|
|
114
|
+
return bytearray()
|
|
115
|
+
|
|
116
|
+
return sbuf
|
|
117
|
+
|
|
118
|
+
async def parse_packet(self, buffer: bytearray) -> dict | None:
|
|
119
|
+
if len(buffer) != self.record_length:
|
|
120
|
+
LOGGER.error("Wrong buffer length: %d", len(buffer))
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
LOGGER.debug("<- %s", ''.join(format(x, '02x') for x in buffer))
|
|
124
|
+
str_buffer = buffer[1:10].decode("cp850")
|
|
125
|
+
|
|
126
|
+
if ID_REPLY in str_buffer:
|
|
127
|
+
LOGGER.info("Device found (%s)", str_buffer)
|
|
128
|
+
self.device_found = True
|
|
129
|
+
|
|
130
|
+
if self.device_found and ID_WAIT_HISTORY in str_buffer:
|
|
131
|
+
await self.send_data(CONTINUE_REQUEST)
|
|
132
|
+
|
|
133
|
+
if buffer[0] == PACKET_ID_HISTORY:
|
|
134
|
+
if self.device_found:
|
|
135
|
+
await self.send_data(START_REQUEST)
|
|
136
|
+
elif buffer[0] == PACKET_ID_REALTIME:
|
|
137
|
+
LOGGER.info("Realtime data received")
|
|
138
|
+
self.device_state = DEVICE_STATES["TransmittingRealtime"]
|
|
139
|
+
res = self.parse_buffer(buffer)
|
|
140
|
+
return res
|
|
141
|
+
elif buffer[0] == PACKET_ID_HISTORY_DATA:
|
|
142
|
+
self.device_state = DEVICE_STATES["TransmittingHistory"]
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
async def read_data(self):
|
|
147
|
+
"""Read data from the serial interface asynchronously."""
|
|
148
|
+
mytime = asyncio.get_event_loop().time()
|
|
149
|
+
if (self.last_poll is not None) and \
|
|
150
|
+
(mytime - self.last_poll) <= 15 and \
|
|
151
|
+
self._data is not None:
|
|
152
|
+
return self._data
|
|
153
|
+
|
|
154
|
+
res = None
|
|
155
|
+
finished = False
|
|
156
|
+
|
|
157
|
+
while not finished:
|
|
158
|
+
packet = await self.get_packet()
|
|
159
|
+
if packet:
|
|
160
|
+
result = await self.parse_packet(packet)
|
|
161
|
+
if result is not None:
|
|
162
|
+
res = result
|
|
163
|
+
finished = True
|
|
164
|
+
|
|
165
|
+
self._data = res
|
|
166
|
+
self.last_poll = asyncio.get_event_loop().time()
|
|
167
|
+
return res
|
|
168
|
+
|
|
169
|
+
def parse_buffer(self, sbuf):
|
|
170
|
+
"""Parse the buffer and return the CM values."""
|
|
171
|
+
res = {}
|
|
172
|
+
for pmname in CMVALS:
|
|
173
|
+
offset = self.config[pmname]
|
|
174
|
+
if offset is not None:
|
|
175
|
+
if self.byte_order == MSB:
|
|
176
|
+
res[pmname] = sbuf[offset] * \
|
|
177
|
+
256 + sbuf[offset + 1]
|
|
178
|
+
else:
|
|
179
|
+
res[pmname] = sbuf[offset + 1] * \
|
|
180
|
+
256 + sbuf[offset]
|
|
181
|
+
|
|
182
|
+
res[pmname] = round(res[pmname] * self.multiplier, 1)
|
|
183
|
+
|
|
184
|
+
return res
|
|
185
|
+
|
|
186
|
+
def supported_values(self) -> list:
|
|
187
|
+
"""Returns the list of supported values for the actual device"""
|
|
188
|
+
res = []
|
|
189
|
+
for pmname in CMVALS:
|
|
190
|
+
offset = self.config[pmname]
|
|
191
|
+
if offset is not None:
|
|
192
|
+
res.append(pmname)
|
|
193
|
+
return res
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: owlsensor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2
|
|
4
4
|
Summary: Library to read data from OWL Energy meters
|
|
5
5
|
Home-page: https://github.com/PBrunot/owlsensor
|
|
6
6
|
Author: Pascal Brunot
|
|
@@ -16,6 +16,6 @@ Classifier: Programming Language :: Python :: 3.4
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.5
|
|
17
17
|
Description-Content-Type: text/x-rst
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: pyserial>=
|
|
19
|
+
Requires-Dist: pyserial-asyncio-fast>=0.14
|
|
20
20
|
|
|
21
21
|
This package is designed for integrating into Home Assistant a serial-connected OWL energy meter.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyserial-asyncio-fast>=0.14
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from setuptools import setup, find_packages
|
|
2
2
|
|
|
3
3
|
setup(name='owlsensor',
|
|
4
|
-
version='0.
|
|
4
|
+
version='0.2',
|
|
5
5
|
description='Library to read data from OWL Energy meters',
|
|
6
6
|
long_description='This package is designed for integrating into Home Assistant a serial-connected OWL energy meter.',
|
|
7
7
|
long_description_content_type = 'text/x-rst',
|
|
@@ -19,6 +19,6 @@ setup(name='owlsensor',
|
|
|
19
19
|
'Programming Language :: Python :: 3.5'
|
|
20
20
|
],
|
|
21
21
|
packages=find_packages(),
|
|
22
|
-
install_requires=['pyserial>=
|
|
22
|
+
install_requires=['pyserial-asyncio-fast>=0.14'],
|
|
23
23
|
keywords='serial owl cm160 energy_meter homeautomation',
|
|
24
24
|
zip_safe=False)
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Reading data from particulate matter sensors with a serial interface.
|
|
3
|
-
"""
|
|
4
|
-
import time
|
|
5
|
-
import threading
|
|
6
|
-
import logging
|
|
7
|
-
|
|
8
|
-
import serial
|
|
9
|
-
|
|
10
|
-
STARTBLOCK = "SB"
|
|
11
|
-
RECORD_LENGTH = "RL"
|
|
12
|
-
# Ofsets of the PM data (always 2 byte)
|
|
13
|
-
CURRENT = "Current"
|
|
14
|
-
BAUD_RATE = "BAUD"
|
|
15
|
-
BYTE_ORDER = "BO",
|
|
16
|
-
LSB = "lsb"
|
|
17
|
-
MSB = "msb"
|
|
18
|
-
DTR_ON = "DTR"
|
|
19
|
-
DTR_OFF = "NOT_DTR"
|
|
20
|
-
MULTIPLIER = "MP"
|
|
21
|
-
TIMEOUT = "TO"
|
|
22
|
-
|
|
23
|
-
# Owl CM160 settings
|
|
24
|
-
OWL_CM160 = {
|
|
25
|
-
"TheOWL": "CM160",
|
|
26
|
-
STARTBLOCK: bytes([0x42, 0x4d, 0x00, 0x14]),
|
|
27
|
-
RECORD_LENGTH: 24,
|
|
28
|
-
CURRENT: 8,
|
|
29
|
-
BAUD_RATE: 250000,
|
|
30
|
-
BYTE_ORDER: MSB,
|
|
31
|
-
MULTIPLIER: 0.07,
|
|
32
|
-
TIMEOUT: 2
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
SUPPORTED_SENSORS = {
|
|
36
|
-
"TheOWL,CM160": OWL_CM160
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
CMVALS=[CURRENT]
|
|
40
|
-
|
|
41
|
-
LOGGER = logging.getLogger(__name__)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class CMDataCollector():
|
|
45
|
-
"""Controls the serial interface and reads data from the sensor."""
|
|
46
|
-
|
|
47
|
-
# pylint: disable=too-many-instance-attributes
|
|
48
|
-
def __init__(self,
|
|
49
|
-
serialdevice,
|
|
50
|
-
configuration,
|
|
51
|
-
power_control=DTR_ON,
|
|
52
|
-
scan_interval=0):
|
|
53
|
-
"""Initialize the data collector based on the given parameters."""
|
|
54
|
-
|
|
55
|
-
self.record_length = configuration[RECORD_LENGTH]
|
|
56
|
-
self.start_sequence = configuration[STARTBLOCK]
|
|
57
|
-
self.byte_order = configuration[BYTE_ORDER]
|
|
58
|
-
self.multiplier = configuration[MULTIPLIER]
|
|
59
|
-
self.timeout = configuration[TIMEOUT]
|
|
60
|
-
self.scan_interval = scan_interval
|
|
61
|
-
self.listeners = []
|
|
62
|
-
self.power_control = power_control
|
|
63
|
-
self.sensordata = {}
|
|
64
|
-
self.config = configuration
|
|
65
|
-
self.data = None
|
|
66
|
-
self.last_poll = None
|
|
67
|
-
self.start_func = None
|
|
68
|
-
self.stop_func = None
|
|
69
|
-
|
|
70
|
-
self.ser = serial.Serial(port=serialdevice,
|
|
71
|
-
baudrate=configuration[BAUD_RATE],
|
|
72
|
-
parity=serial.PARITY_NONE,
|
|
73
|
-
stopbits=serial.STOPBITS_ONE,
|
|
74
|
-
bytesize=serial.EIGHTBITS,
|
|
75
|
-
timeout=0.5)
|
|
76
|
-
|
|
77
|
-
# Update date in using a background thread
|
|
78
|
-
if self.scan_interval > 0:
|
|
79
|
-
thread = threading.Thread(target=self.refresh, args=())
|
|
80
|
-
thread.daemon = True
|
|
81
|
-
thread.start()
|
|
82
|
-
|
|
83
|
-
def refresh(self):
|
|
84
|
-
"""Background refreshing thread."""
|
|
85
|
-
while True:
|
|
86
|
-
self.read_data()
|
|
87
|
-
time.sleep(self.scan_interval)
|
|
88
|
-
|
|
89
|
-
# pylint: disable=too-many-branches
|
|
90
|
-
def read_data(self):
|
|
91
|
-
"""Read data from serial interface and return it as a dictionary.
|
|
92
|
-
|
|
93
|
-
There is some caching implemented the sensor won't be polled twice
|
|
94
|
-
within a 15 second interval. If data is requested within 15 seconds
|
|
95
|
-
after it has been read, the data from the last read_data operation will
|
|
96
|
-
be returned again
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
mytime = time.time()
|
|
100
|
-
if (self.last_poll is not None) and \
|
|
101
|
-
(mytime - self.last_poll) <= 15:
|
|
102
|
-
return self._data
|
|
103
|
-
|
|
104
|
-
# Start function that can do several things (e.g. turning the
|
|
105
|
-
# sensor on)
|
|
106
|
-
if self.start_func:
|
|
107
|
-
self.start_func(self.ser)
|
|
108
|
-
|
|
109
|
-
res = None
|
|
110
|
-
finished = False
|
|
111
|
-
sbuf = bytearray()
|
|
112
|
-
starttime = time.time()
|
|
113
|
-
checkCode = int(0);
|
|
114
|
-
expectedCheckCode = int()
|
|
115
|
-
#it is necessary to reset input buffer because data is cotinously received by the system and placed in the device buffer when serial is open.
|
|
116
|
-
#But "Home Assistant" code read it only from time to time so the data we read here would be placed in the past.
|
|
117
|
-
#Better is to clean the buffer and read new data from "present" time.
|
|
118
|
-
self.ser.reset_input_buffer()
|
|
119
|
-
while not finished:
|
|
120
|
-
mytime = time.time()
|
|
121
|
-
if mytime - starttime > self.timeout:
|
|
122
|
-
LOGGER.error("read timeout after %s seconds, read %s bytes",
|
|
123
|
-
self.timeout, len(sbuf))
|
|
124
|
-
return {}
|
|
125
|
-
|
|
126
|
-
if self.ser.inWaiting() > 0:
|
|
127
|
-
sbuf += self.ser.read(1)
|
|
128
|
-
if len(sbuf) == len(self.start_sequence):
|
|
129
|
-
if sbuf == self.start_sequence:
|
|
130
|
-
LOGGER.debug("Found start sequence %s",
|
|
131
|
-
self.start_sequence)
|
|
132
|
-
else:
|
|
133
|
-
LOGGER.debug("Start sequence not yet found")
|
|
134
|
-
# Remove first character
|
|
135
|
-
sbuf = sbuf[1:]
|
|
136
|
-
|
|
137
|
-
if len(sbuf) == self.record_length:
|
|
138
|
-
#Check the control sum if it is known how to do it
|
|
139
|
-
if self.config == PLANTOWER1:
|
|
140
|
-
for c in sbuf[0:(self.record_length-2)]:
|
|
141
|
-
checkCode += c
|
|
142
|
-
expectedCheckCode = sbuf[30]*256 + sbuf[31]
|
|
143
|
-
if checkCode != expectedCheckCode:
|
|
144
|
-
#because of data inconsistency clean the buffer
|
|
145
|
-
LOGGER.error("PM sensor data sum error %d, expected %d", checkCode, expectedCheckCode)
|
|
146
|
-
sbuf = []
|
|
147
|
-
checkCode = 0
|
|
148
|
-
continue
|
|
149
|
-
|
|
150
|
-
#if it is ok then send it for interpretation
|
|
151
|
-
res = self.parse_buffer(sbuf)
|
|
152
|
-
LOGGER.debug("Finished reading data %s", sbuf)
|
|
153
|
-
finished = True
|
|
154
|
-
|
|
155
|
-
else:
|
|
156
|
-
time.sleep(.5)
|
|
157
|
-
LOGGER.debug("Serial waiting for data, buffer length=%s",
|
|
158
|
-
len(sbuf))
|
|
159
|
-
|
|
160
|
-
if self.stop_func:
|
|
161
|
-
self.stop_func(self.ser)
|
|
162
|
-
|
|
163
|
-
self._data = res
|
|
164
|
-
self.last_poll = time.time()
|
|
165
|
-
return res
|
|
166
|
-
|
|
167
|
-
def parse_buffer(self, sbuf):
|
|
168
|
-
"""Parse the buffer and return the CM values."""
|
|
169
|
-
res = {}
|
|
170
|
-
for pmname in CMVALS:
|
|
171
|
-
offset = self.config[pmname]
|
|
172
|
-
if offset is not None:
|
|
173
|
-
if self.byte_order == MSB:
|
|
174
|
-
res[pmname] = sbuf[offset] * \
|
|
175
|
-
256 + sbuf[offset + 1]
|
|
176
|
-
else:
|
|
177
|
-
res[pmname] = sbuf[offset + 1] * \
|
|
178
|
-
256 + sbuf[offset]
|
|
179
|
-
|
|
180
|
-
res[pmname] = round(res[pmname] * self.multiplier, 1)
|
|
181
|
-
|
|
182
|
-
return res
|
|
183
|
-
|
|
184
|
-
def supported_values(self) -> list:
|
|
185
|
-
"""Returns the list of supported values for the actual device"""
|
|
186
|
-
res = []
|
|
187
|
-
for pmname in CMVALS:
|
|
188
|
-
offset = self.config[pmname]
|
|
189
|
-
if offset is not None:
|
|
190
|
-
res.append(pmname)
|
|
191
|
-
return res
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
pyserial>=3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|