├── .gitignore ├── Makefile ├── README ├── README.md ├── rdserial ├── __init__.py ├── device │ └── __init__.py ├── dps │ ├── __init__.py │ └── tool.py ├── modbus │ └── __init__.py ├── tool.py └── um │ ├── __init__.py │ └── tool.py ├── rdserialtool └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | *.deb 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON := python3 2 | PANDOC := pandoc 3 | 4 | all: build 5 | 6 | build: 7 | $(PYTHON) setup.py build 8 | 9 | test: build 10 | $(PYTHON) setup.py test 11 | 12 | install: build 13 | $(PYTHON) setup.py install 14 | 15 | clean: 16 | $(PYTHON) setup.py clean 17 | $(RM) -r build MANIFEST 18 | 19 | doc: README 20 | 21 | README: README.md 22 | $(PANDOC) -s -t plain -o $@ $< 23 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | 2 | 3 | RDSERIALTOOL - RDTECH UM/DPS/RD SERIES DEVICE INTERFACE TOOL 4 | 5 | 6 | _This program is currently in an early stage and could change 7 | significantly._ 8 | 9 | This program provides monitor, control and configuration access to 10 | RDTech (RuiDeng, Riden) UM, DPS and RD series devices. 11 | 12 | The UM24C, UM25C and UM34C are low-cost USB pass-through power 13 | measurement devices, and support a decent number of collection features, 14 | as well as full control via Bluetooth. (The non-C versions of these 15 | devices support the same features as the C versions, but without 16 | Bluetooth control.) 17 | 18 | The DPS series are programmable DC-DC power supplies, and many devices 19 | in the series support external communication via the Modbus RTU serial 20 | protocol over USB or Bluetooth. 21 | 22 | The RD6006 is a logical continuation of the DPS series and also uses 23 | Modbus communication, but the registers are incompatible with previous 24 | DPS series, so “RD” is treated as a separate series. 25 | 26 | 27 | Compatibility 28 | 29 | - UM24C, UM25C and UM34C support is complete and tested. 30 | - DPS5005 support is complete and tested. Other devices in the DPS 31 | series (DPS3005, DPS5015, DPS5020, DPS8005, DPH5005) should perform 32 | identically. (Status reports and bugs welcome.) 33 | - RD6006 has basic support and testing. Reading and writing most 34 | states work. 35 | - Tested under Python 3.6, but should work with 3.4 or later. 36 | - Linux: Tested fine with both PyBluez (direct) and pyserial 37 | (e.g. /dev/rfcomm0 via rfcomm bind), as well as direct USB serial 38 | (e.g. /dev/ttyUSB0) on DPS devices. 39 | - Windows: Tested fine with pyserial (e.g. COM4 as set up 40 | automatically by Windows). Author could not get PyBluez 41 | compiled/installed. 42 | - MacOS: When using pyserial (e.g. /dev/cu.UM24C-Port as set up 43 | automatically by MacOS), writes to the device would succeed 44 | (e.g. 0xf2 to rotate the screen on UM series), but reads from the 45 | device never arrive. Author could not get PyBluez 46 | compiled/installed. 47 | 48 | 49 | Setup 50 | 51 | rdserialtool requires Python 3, and PyBluez and/or pyserial modules, 52 | depending on which method you use to connect. Installation varies by 53 | operating system, but on Debian/Ubuntu, these are available via the 54 | python3-pybluez and python3-serial packages, respectively. 55 | 56 | To install rdserialtool: 57 | 58 | $ sudo python3 setup.py install 59 | 60 | rdserialtool may also be run directly from its source directory without 61 | installation. 62 | 63 | 64 | Bluetooth setup 65 | 66 | Varies by operating system. If the pairing procedure asks for a PIN, 67 | enter 1234. 68 | 69 | For command-line installation on Linux: 70 | 71 | $ bluetoothctl 72 | Agent registered 73 | [bluetooth]# scan on 74 | Discovery started 75 | [NEW] Device 00:90:72:56:98:D7 UM24C 76 | [CHG] Device 00:90:72:56:98:D7 RSSI: -60 77 | [bluetooth]# pair 00:90:72:56:98:D7 78 | Attempting to pair with 00:90:72:56:98:D7 79 | [CHG] Device 00:90:72:56:98:D7 Connected: yes 80 | Request PIN code 81 | [UM241m[agent] Enter PIN code: 1234 82 | [CHG] Device 00:90:72:56:98:D7 UUIDs: 00001101-0000-1000-8000-00805f9b34fb 83 | [CHG] Device 00:90:72:56:98:D7 ServicesResolved: yes 84 | [CHG] Device 00:90:72:56:98:D7 Paired: yes 85 | Pairing successful 86 | [bluetooth]# trust 00:90:72:56:98:D7 87 | [CHG] Device 00:90:72:56:98:D7 Trusted: yes 88 | Changing 00:90:72:56:98:D7 trust succeeded 89 | [bluetooth]# exit 90 | Agent unregistered 91 | 92 | Device MAC address will vary. Again, the PIN for the device is 1234. 93 | 94 | If you then want to use rdserialtool via direct serial, bind it via 95 | rfcomm: 96 | 97 | $ sudo rfcomm bind 0 00:90:72:56:98:D7 98 | 99 | 100 | Usage 101 | 102 | A number of options common to device access are available to all 103 | commands; see: 104 | 105 | $ rdserialtool --help 106 | 107 | After the common options, a command is required (commands available are 108 | in --help above). For example, to get device information from a UM24C 109 | via PyBluez: 110 | 111 | $ rdserialtool --device=um24c --bluetooth-address=00:90:72:56:98:D7 112 | 113 | Or via pyserial: 114 | 115 | $ rdserialtool --device=um24c --serial-device=/dev/rfcomm0 116 | 117 | To turn the output on for a DPS device: 118 | 119 | $ rdserialtool --device=dps --bluetooth-address=00:BA:68:00:47:3A --on 120 | 121 | 122 | Example 123 | 124 | $ rdserialtool --device=um25c --bluetooth-address=00:15:A6:00:36:2F 125 | rdserialtool 126 | Copyright (C) 2019 Ryan Finnie 127 | 128 | Connecting to UM25C 00:15:A6:00:36:2F 129 | Connection established 130 | 131 | USB: 5.062V, 0.1146A, 0.580W, 44.1Ω 132 | Data: 0.01V(+), 0.00V(-), charging mode: DCP 1.5A 133 | Recording (off): 0.000Ah, 0.000Wh, 0 sec at >= 0.13A 134 | Data groups: 135 | *0: 0.001Ah, 0.009Wh 5: 0.000Ah, 0.000Wh 136 | 1: 0.000Ah, 0.000Wh 6: 0.000Ah, 0.000Wh 137 | 2: 0.000Ah, 0.000Wh 7: 0.000Ah, 0.000Wh 138 | 3: 0.000Ah, 0.000Wh 8: 0.000Ah, 0.000Wh 139 | 4: 0.000Ah, 0.000Wh 9: 0.000Ah, 0.000Wh 140 | UM25C, temperature: 25C ( 78F) 141 | Screen: 1/6, brightness: 4/5, timeout: 2 min 142 | Collection time: 2019-02-23 22:53:08.468732 143 | 144 | $ rdserialtool --device=dps --bluetooth-address=00:BA:68:00:47:3A 145 | rdserialtool 146 | Copyright (C) 2019 Ryan Finnie 147 | 148 | Connecting to DPS 00:BA:68:00:47:3A 149 | Connection established 150 | 151 | Setting: 5.00V, 5.100A (CV) 152 | Output (on) : 5.00V, 0.15A, 0.07W 153 | Input: 19.30V, protection: good 154 | Brightness: 4/5, key lock: off 155 | Model: 5005, firmware: 14 156 | Collection time: 2019-02-23 22:55:24.721946 157 | 158 | $ rdserialtool --device=rd --serial-device=/dev/ttyUSB0 --baud=115200 159 | rdserialtool 160 | Copyright (C) 2019 Ryan Finnie 161 | 162 | Connecting to RD /dev/ttyUSB0 163 | Connection established 164 | 165 | Setting: 15.00V, 0.998A (CV) 166 | Output (on) : 14.99V, 0.14A, 0.20W 167 | Input: 50.15V, protection: good 168 | Brightness: 4/5, key lock: off 169 | Model: 60062, firmware: 125, serial: 5403 170 | Collection time: 2019-12-28 21:16:07.114146 171 | 172 | 173 | About 174 | 175 | Copyright (C) 2019 Ryan Finnie 176 | 177 | This program is free software; you can redistribute it and/or modify 178 | it under the terms of the GNU General Public License as published by 179 | the Free Software Foundation; either version 2 of the License, or (at 180 | your option) any later version. 181 | 182 | This program is distributed in the hope that it will be useful, but 183 | WITHOUT ANY WARRANTY; without even the implied warranty of 184 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 185 | General Public License for more details. 186 | 187 | This tool is not affiliated with or endorsed by RDTech. 188 | 189 | 190 | See also 191 | 192 | - RDTech UM series on the sigrok wiki, which contains a lot of 193 | information and reverse engineering of the protocol used on these 194 | devices. 195 | - DPS5005 communication protocol and Android/Windows software, from 196 | the manufacturer. 197 | - opendps, a replacement firmware package for the DPS5005. 198 | (Incompatible with rdserialtool, as opendps uses its own 199 | communication interface.) 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rdserialtool - RDTech UM/DPS/RD series device interface tool 2 | 3 | *This program is currently in an early stage and could change significantly.* 4 | 5 | This program provides monitor, control and configuration access to [RDTech (RuiDeng, Riden)](https://rdtech.aliexpress.com/store/923042) UM, DPS and RD series devices. 6 | 7 | The [UM24C](https://www.aliexpress.com/item/RD-UM24-UM24C-for-APP-USB-2-0-LCD-Display-Voltmeter-ammeter-battery-charge-voltage-current/32845522857.html), [UM25C](https://www.aliexpress.com/store/product/RD-UM25-UM25C-for-APP-USB-2-0-Type-C-LCD-Voltmeter-ammeter-voltage-current-meter/923042_32855845265.html) and [UM34C](https://www.aliexpress.com/store/product/RD-UM34-UM34C-for-APP-USB-3-0-Type-C-DC-Voltmeter-ammeter-voltage-current-meter/923042_32880908871.html) are low-cost USB pass-through power measurement devices, and support a decent number of collection features, as well as full control via Bluetooth. (The non-C versions of these devices support the same features as the C versions, but without Bluetooth control.) 8 | 9 | The [DPS series](https://rdtech.aliexpress.com/store/923042) are programmable DC-DC power supplies, and many devices in the series support external communication via the [Modbus](https://en.wikipedia.org/wiki/Modbus) RTU serial protocol over USB or Bluetooth. 10 | 11 | The RD6006 is a logical continuation of the DPS series and also uses Modbus communication, but the registers are incompatible with previous DPS series, so "RD" is treated as a separate series. 12 | 13 | ## Compatibility 14 | 15 | * UM24C, UM25C and UM34C support is complete and tested. 16 | * DPS5005 support is complete and tested. Other devices in the DPS series (DPS3005, DPS5015, DPS5020, DPS8005, DPH5005) should perform identically. (Status reports and bugs welcome.) 17 | * RD6006 has basic support and testing. Reading and writing most states work. 18 | * Tested under Python 3.6, but should work with 3.4 or later. 19 | * Linux: Tested fine with both PyBluez (direct) and pyserial (e.g. /dev/rfcomm0 via ```rfcomm bind```), as well as direct USB serial (e.g. /dev/ttyUSB0) on DPS devices. 20 | * Windows: Tested fine with pyserial (e.g. COM4 as set up automatically by Windows). Author could not get PyBluez compiled/installed. 21 | * MacOS: When using pyserial (e.g. /dev/cu.UM24C-Port as set up automatically by MacOS), writes to the device would succeed (e.g. 0xf2 to rotate the screen on UM series), but reads from the device never arrive. Author could not get PyBluez compiled/installed. 22 | 23 | ## Setup 24 | 25 | rdserialtool requires Python 3, and [PyBluez](https://pypi.org/project/PyBluez/) and/or [pyserial](https://pypi.org/project/pyserial/) modules, depending on which method you use to connect. Installation varies by operating system, but on Debian/Ubuntu, these are available via the python3-pybluez and python3-serial packages, respectively. 26 | 27 | To install rdserialtool: 28 | 29 | ``` 30 | $ sudo python3 setup.py install 31 | ``` 32 | 33 | rdserialtool may also be run directly from its source directory without installation. 34 | 35 | ## Bluetooth setup 36 | 37 | Varies by operating system. If the pairing procedure asks for a PIN, enter 1234. 38 | 39 | For command-line installation on Linux: 40 | 41 | ``` 42 | $ bluetoothctl 43 | Agent registered 44 | [bluetooth]# scan on 45 | Discovery started 46 | [NEW] Device 00:90:72:56:98:D7 UM24C 47 | [CHG] Device 00:90:72:56:98:D7 RSSI: -60 48 | [bluetooth]# pair 00:90:72:56:98:D7 49 | Attempting to pair with 00:90:72:56:98:D7 50 | [CHG] Device 00:90:72:56:98:D7 Connected: yes 51 | Request PIN code 52 | [UM241m[agent] Enter PIN code: 1234 53 | [CHG] Device 00:90:72:56:98:D7 UUIDs: 00001101-0000-1000-8000-00805f9b34fb 54 | [CHG] Device 00:90:72:56:98:D7 ServicesResolved: yes 55 | [CHG] Device 00:90:72:56:98:D7 Paired: yes 56 | Pairing successful 57 | [bluetooth]# trust 00:90:72:56:98:D7 58 | [CHG] Device 00:90:72:56:98:D7 Trusted: yes 59 | Changing 00:90:72:56:98:D7 trust succeeded 60 | [bluetooth]# exit 61 | Agent unregistered 62 | ``` 63 | 64 | Device MAC address will vary. Again, the PIN for the device is 1234. 65 | 66 | If you then want to use rdserialtool via direct serial, bind it via rfcomm: 67 | 68 | ``` 69 | $ sudo rfcomm bind 0 00:90:72:56:98:D7 70 | ``` 71 | 72 | ## Usage 73 | 74 | A number of options common to device access are available to all commands; see: 75 | 76 | ``` 77 | $ rdserialtool --help 78 | ``` 79 | 80 | After the common options, a command is required (commands available are in ```--help``` above). For example, to get device information from a UM24C via PyBluez: 81 | 82 | ``` 83 | $ rdserialtool --device=um24c --bluetooth-address=00:90:72:56:98:D7 84 | ``` 85 | 86 | Or via pyserial: 87 | 88 | ``` 89 | $ rdserialtool --device=um24c --serial-device=/dev/rfcomm0 90 | ``` 91 | 92 | To turn the output on for a DPS device: 93 | 94 | ``` 95 | $ rdserialtool --device=dps --bluetooth-address=00:BA:68:00:47:3A --on 96 | ``` 97 | 98 | ## Example 99 | 100 | ``` 101 | $ rdserialtool --device=um25c --bluetooth-address=00:15:A6:00:36:2F 102 | rdserialtool 103 | Copyright (C) 2019 Ryan Finnie 104 | 105 | Connecting to UM25C 00:15:A6:00:36:2F 106 | Connection established 107 | 108 | USB: 5.062V, 0.1146A, 0.580W, 44.1Ω 109 | Data: 0.01V(+), 0.00V(-), charging mode: DCP 1.5A 110 | Recording (off): 0.000Ah, 0.000Wh, 0 sec at >= 0.13A 111 | Data groups: 112 | *0: 0.001Ah, 0.009Wh 5: 0.000Ah, 0.000Wh 113 | 1: 0.000Ah, 0.000Wh 6: 0.000Ah, 0.000Wh 114 | 2: 0.000Ah, 0.000Wh 7: 0.000Ah, 0.000Wh 115 | 3: 0.000Ah, 0.000Wh 8: 0.000Ah, 0.000Wh 116 | 4: 0.000Ah, 0.000Wh 9: 0.000Ah, 0.000Wh 117 | UM25C, temperature: 25C ( 78F) 118 | Screen: 1/6, brightness: 4/5, timeout: 2 min 119 | Collection time: 2019-02-23 22:53:08.468732 120 | ``` 121 | 122 | ``` 123 | $ rdserialtool --device=dps --bluetooth-address=00:BA:68:00:47:3A 124 | rdserialtool 125 | Copyright (C) 2019 Ryan Finnie 126 | 127 | Connecting to DPS 00:BA:68:00:47:3A 128 | Connection established 129 | 130 | Setting: 5.00V, 5.100A (CV) 131 | Output (on) : 5.00V, 0.15A, 0.07W 132 | Input: 19.30V, protection: good 133 | Brightness: 4/5, key lock: off 134 | Model: 5005, firmware: 14 135 | Collection time: 2019-02-23 22:55:24.721946 136 | ``` 137 | 138 | ``` 139 | $ rdserialtool --device=rd --serial-device=/dev/ttyUSB0 --baud=115200 140 | rdserialtool 141 | Copyright (C) 2019 Ryan Finnie 142 | 143 | Connecting to RD /dev/ttyUSB0 144 | Connection established 145 | 146 | Setting: 15.00V, 0.998A (CV) 147 | Output (on) : 14.99V, 0.14A, 0.20W 148 | Input: 50.15V, protection: good 149 | Brightness: 4/5, key lock: off 150 | Model: 60062, firmware: 125, serial: 5403 151 | Collection time: 2019-12-28 21:16:07.114146 152 | ``` 153 | 154 | ## About 155 | 156 | Copyright (C) 2019 Ryan Finnie 157 | 158 | > This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. 159 | > 160 | > This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 161 | 162 | This tool is not affiliated with or endorsed by RDTech. 163 | 164 | ## See also 165 | 166 | * [RDTech UM series](https://sigrok.org/wiki/RDTech_UM_series) on the sigrok wiki, which contains a lot of information and reverse engineering of the protocol used on these devices. 167 | * [DPS5005 communication protocol](https://www.mediafire.com/folder/3iogirsx1s0vp/DPS_communication_upper_computer#napmdzd4qt2dt) and Android/Windows software, from the manufacturer. 168 | * [opendps](https://github.com/kanflo/opendps), a replacement firmware package for the DPS5005. (Incompatible with rdserialtool, as opendps uses its own communication interface.) 169 | -------------------------------------------------------------------------------- /rdserial/__init__.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import sys 20 | 21 | assert(sys.version_info > (3, 4)) 22 | 23 | __version__ = '0.2.1' 24 | -------------------------------------------------------------------------------- /rdserial/device/__init__.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import logging 20 | 21 | try: 22 | import bluetooth 23 | HAS_BLUETOOTH = True 24 | except ImportError: 25 | HAS_BLUETOOTH = False 26 | try: 27 | import serial 28 | HAS_SERIAL = True 29 | except ImportError: 30 | HAS_SERIAL = False 31 | 32 | 33 | class Serial: 34 | def __init__(self, port, baudrate=9600): 35 | if not HAS_SERIAL: 36 | raise NotImplementedError('pyserial not available') 37 | 38 | self.port = port 39 | self.baudrate = baudrate 40 | self.socket = None 41 | 42 | def connect(self): 43 | if self.socket: 44 | return True 45 | logging.debug('Serial: Connecting to {}'.format(self.port)) 46 | self.socket = serial.Serial() 47 | self.socket.port = self.port 48 | self.socket.baudrate = self.baudrate 49 | self.socket.writeTimeout = 0 50 | self.socket.open() 51 | return self.socket is not None 52 | 53 | def close(self): 54 | if self.socket: 55 | self.socket.close() 56 | self.socket = None 57 | 58 | def send(self, request): 59 | if not request: 60 | return 0 61 | logging.debug('Serial: SEND begin ({})'.format(request)) 62 | size = self.socket.write(request) 63 | logging.debug('Serial: SEND end ({} bytes)'.format(size)) 64 | return size 65 | 66 | def recv(self, size): 67 | result = b'' 68 | logging.debug('Serial: RECV begin') 69 | while len(result) < size: 70 | buf = self.socket.read() 71 | result += buf 72 | logging.debug('Serial: RECV end ({})'.format(result)) 73 | return result 74 | 75 | def __str__(self): 76 | return '%s' % self.port 77 | 78 | 79 | class Bluetooth: 80 | def __init__(self, address, port=1): 81 | if not HAS_BLUETOOTH: 82 | raise NotImplementedError('pybluez not available') 83 | 84 | self.address = address 85 | self.port = port 86 | self.socket = None 87 | 88 | def connect(self): 89 | if self.socket: 90 | return True 91 | logging.debug('Bluetooth: Connecting to {} port {}'.format(self.address, self.port)) 92 | self.socket = bluetooth.BluetoothSocket(bluetooth.RFCOMM) 93 | self.socket.connect((self.address, self.port)) 94 | return self.socket is not None 95 | 96 | def close(self): 97 | if self.socket: 98 | self.socket.close() 99 | self.socket = None 100 | 101 | def send(self, request): 102 | if not request: 103 | return 0 104 | 105 | logging.debug('Bluetooth: SEND begin ({})'.format(request)) 106 | size = self.socket.send(request) 107 | logging.debug('Bluetooth: SEND end ({} bytes)'.format(size)) 108 | return size 109 | 110 | def recv(self, size): 111 | result = b'' 112 | logging.debug('Bluetooth: RECV begin') 113 | while len(result) < size: 114 | buf = self.socket.recv(size) 115 | result += buf 116 | logging.debug('Bluetooth: RECV end ({})'.format(result)) 117 | return result 118 | 119 | def __str__(self): 120 | return '%s:%s' % (self.address, self.port) 121 | -------------------------------------------------------------------------------- /rdserial/dps/__init__.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import datetime 20 | 21 | PROTECTION_GOOD = 0 22 | PROTECTION_OV = 1 23 | PROTECTION_OC = 2 24 | PROTECTION_OP = 3 25 | 26 | 27 | def _simple_int(multiple=1): 28 | return { 29 | 'from_int': lambda x: x / multiple, 30 | 'to_int': lambda x: int(x * multiple), 31 | } 32 | 33 | 34 | def _simple_bool(): 35 | return { 36 | 'from_int': lambda x: bool(x), 37 | 'to_int': lambda x: int(x), 38 | } 39 | 40 | 41 | class DPSDeviceState: 42 | def __init__(self, collection_time=None): 43 | self.register_properties = { 44 | 'setting_volts': { 45 | 'description': 'Voltage setting', 46 | 'register': 0x00, 47 | **_simple_int(100), 48 | }, 49 | 'setting_amps': { 50 | 'description': 'Amperage setting', 51 | 'register': 0x01, 52 | **_simple_int(1000), 53 | }, 54 | 'volts': { 55 | 'description': 'Output volts', 56 | 'register': 0x02, 57 | **_simple_int(100), 58 | }, 59 | 'amps': { 60 | 'description': 'Output amps', 61 | 'register': 0x03, 62 | **_simple_int(100), 63 | }, 64 | 'watts': { 65 | 'description': 'Output watts', 66 | 'register': 0x04, 67 | **_simple_int(100), 68 | }, 69 | 'input_volts': { 70 | 'description': 'Input volts', 71 | 'register': 0x05, 72 | **_simple_int(100), 73 | }, 74 | 'key_lock': { 75 | 'description': 'Key lock', 76 | 'register': 0x06, 77 | **_simple_bool(), 78 | }, 79 | 'protection': { 80 | 'description': 'Protection status', 81 | 'register': 0x07, 82 | **_simple_int(), 83 | }, 84 | 'constant_current': { 85 | 'description': 'Constant current mode', 86 | 'register': 0x08, 87 | **_simple_bool(), 88 | }, 89 | 'output_state': { 90 | 'description': 'Output state', 91 | 'register': 0x09, 92 | **_simple_bool(), 93 | }, 94 | 'brightness': { 95 | 'description': 'Brightness level', 96 | 'register': 0x0a, 97 | **_simple_int(), 98 | }, 99 | 'model': { 100 | 'description': 'Device model', 101 | 'register': 0x0b, 102 | **_simple_int(), 103 | }, 104 | 'firmware': { 105 | 'description': 'Device firmware', 106 | 'register': 0x0c, 107 | **_simple_int(), 108 | }, 109 | 'group_loader': { 110 | 'description': 'Group loader', 111 | 'register': 0x23, 112 | 'from_int': lambda x: 0, # Write-only 113 | 'to_int': lambda x: int(x), 114 | }, 115 | } 116 | 117 | if collection_time is None: 118 | collection_time = datetime.datetime.now() 119 | self.collection_time = collection_time 120 | for name in self.register_properties: 121 | setattr(self, name, self.register_properties[name]['from_int'](0)) 122 | self.groups = {} 123 | 124 | def load(self, data, offset=0): 125 | pos_map = {v['register']: k for k, v in self.register_properties.items()} 126 | i = 0 127 | for raw_val in data: 128 | val_pos = offset + i 129 | if val_pos in pos_map: 130 | name = pos_map[val_pos] 131 | translation = self.register_properties[name]['from_int'] 132 | val = translation(raw_val) 133 | setattr(self, name, val) 134 | i = i + 1 135 | 136 | 137 | class DPSGroupState: 138 | def __init__(self, group): 139 | self.group = group 140 | self.register_properties = { 141 | 'setting_volts': { 142 | 'description': 'Voltage setting', 143 | 'register': 0x50 + (0x10 * group), 144 | **_simple_int(100), 145 | }, 146 | 'setting_amps': { 147 | 'description': 'Amperage setting', 148 | 'register': 0x51 + (0x10 * group), 149 | **_simple_int(1000), 150 | }, 151 | 'cutoff_volts': { 152 | 'description': 'Volts cutoff', 153 | 'register': 0x52 + (0x10 * group), 154 | **_simple_int(100), 155 | }, 156 | 'cutoff_amps': { 157 | 'description': 'Amps cutoff', 158 | 'register': 0x53 + (0x10 * group), 159 | **_simple_int(1000), 160 | }, 161 | 'cutoff_watts': { 162 | 'description': 'Watts cutoff', 163 | 'register': 0x54 + (0x10 * group), 164 | **_simple_int(10), 165 | }, 166 | 'brightness': { 167 | 'description': 'Brightness level', 168 | 'register': 0x55 + (0x10 * group), 169 | **_simple_int(), 170 | }, 171 | 'maintain_output': { 172 | 'description': 'Maintain output state during group change', 173 | 'register': 0x56 + (0x10 * group), 174 | **_simple_bool(), 175 | }, 176 | 'poweron_output': { 177 | 'description': 'Enable output on power-on', 178 | 'register': 0x57 + (0x10 * group), 179 | **_simple_bool(), 180 | }, 181 | } 182 | 183 | for name in self.register_properties: 184 | setattr(self, name, self.register_properties[name]['from_int'](0)) 185 | 186 | def load(self, data, offset=0): 187 | pos_map = {v['register']: k for k, v in self.register_properties.items()} 188 | i = 0 189 | for raw_val in data: 190 | val_pos = offset + i 191 | if val_pos in pos_map: 192 | name = pos_map[val_pos] 193 | translation = self.register_properties[name]['from_int'] 194 | val = translation(raw_val) 195 | setattr(self, name, val) 196 | i = i + 1 197 | 198 | 199 | class RDDeviceState: 200 | def __init__(self, collection_time=None): 201 | self.register_properties = { 202 | 'model': { 203 | 'description': 'Device model', 204 | 'register': 0x00, 205 | **_simple_int(), 206 | }, 207 | 'serial': { 208 | 'description': 'Device serial', 209 | 'register': 0x02, # 0x01 high? 210 | **_simple_int(), 211 | }, 212 | 'firmware': { 213 | 'description': 'Device firmware', 214 | 'register': 0x03, 215 | **_simple_int(), 216 | }, 217 | 'fan_temp_c': { 218 | 'description': 'Fan start temperature (C)', 219 | 'register': 0x05, # 0x04 high? 220 | **_simple_int(), 221 | }, 222 | 'fan_temp_f': { 223 | 'description': 'Fan start temperature (F)', 224 | 'register': 0x07, # 0x06 high? 225 | **_simple_int(), 226 | }, 227 | 'setting_volts': { 228 | 'description': 'Voltage setting', 229 | 'register': 0x08, 230 | **_simple_int(100), 231 | }, 232 | 'setting_amps': { 233 | 'description': 'Amperage setting', 234 | 'register': 0x09, 235 | **_simple_int(1000), 236 | }, 237 | 'volts': { 238 | 'description': 'Output volts', 239 | 'register': 0x0a, 240 | **_simple_int(100), 241 | }, 242 | 'amps': { 243 | 'description': 'Output amps', 244 | 'register': 0x0b, 245 | **_simple_int(100), 246 | }, 247 | 'watts': { 248 | 'description': 'Output watts', 249 | 'register': 0x0d, # 0x0c high? 250 | **_simple_int(100), 251 | }, 252 | 'input_volts': { 253 | 'description': 'Input volts', 254 | 'register': 0x0e, 255 | **_simple_int(100), 256 | }, 257 | 'key_lock': { 258 | 'description': 'Key lock', 259 | 'register': 0x0f, 260 | **_simple_bool(), 261 | }, 262 | 'protection': { 263 | 'description': 'Protection status', 264 | 'register': 0x10, 265 | **_simple_int(), 266 | }, 267 | 'constant_current': { 268 | 'description': 'Constant current mode', 269 | 'register': 0x11, 270 | **_simple_bool(), 271 | }, 272 | 'output_state': { 273 | 'description': 'Output state', 274 | 'register': 0x12, 275 | **_simple_bool(), 276 | }, 277 | 'group_loader': { 278 | 'description': 'Group loader', 279 | 'register': 0x13, 280 | 'from_int': lambda x: 0, # Write-only 281 | 'to_int': lambda x: int(x), 282 | }, 283 | # 0x14 - 0x2f: All 0 284 | # 0x21: Unknown, 1/2/3 observed 285 | 'temp_c': { 286 | 'description': 'Temperature (C)', 287 | 'register': 0x23, # 0x22 high? 288 | **_simple_int(), 289 | }, 290 | 'temp_f': { 291 | 'description': 'Temperature (F)', 292 | 'register': 0x25, # 0x24 high? 293 | **_simple_int(), 294 | }, 295 | 'cumulative_charge': { 296 | 'description': 'Cumulative charge (Ah)', 297 | 'register': 0x27, # 0x26 high? 298 | **_simple_int(1000), 299 | }, 300 | 'cumulative_energy': { 301 | 'description': 'Cumulative energy (Wh)', 302 | 'register': 0x29, # 0x28 high? 303 | **_simple_int(1000), 304 | }, 305 | 'datetime_year': {'description': 'Year', 'register': 0x30, **_simple_int()}, 306 | 'datetime_month': {'description': 'Month', 'register': 0x31, **_simple_int()}, 307 | 'datetime_day': {'description': 'Day', 'register': 0x32, **_simple_int()}, 308 | 'datetime_hour': {'description': 'Hour', 'register': 0x33, **_simple_int()}, 309 | 'datetime_minute': {'description': 'Minute', 'register': 0x34, **_simple_int()}, 310 | 'datetime_second': {'description': 'Second', 'register': 0x35, **_simple_int()}, 311 | 'brightness': { 312 | 'description': 'Brightness level', 313 | 'register': 0x48, 314 | **_simple_int(), 315 | }, 316 | 'ovp': { 317 | 'description': 'Over-voltage limit (V)', 318 | 'register': 0x52, 319 | **_simple_int(100), 320 | }, 321 | 'ocp': { 322 | 'description': 'Over-current limit (A)', 323 | 'register': 0x53, 324 | **_simple_int(1000), 325 | }, 326 | } 327 | 328 | if collection_time is None: 329 | collection_time = datetime.datetime.now() 330 | self.collection_time = collection_time 331 | for name in self.register_properties: 332 | setattr(self, name, self.register_properties[name]['from_int'](0)) 333 | self.groups = {} 334 | 335 | def load(self, data, offset=0): 336 | pos_map = {v['register']: k for k, v in self.register_properties.items()} 337 | i = 0 338 | for raw_val in data: 339 | val_pos = offset + i 340 | if val_pos in pos_map: 341 | name = pos_map[val_pos] 342 | translation = self.register_properties[name]['from_int'] 343 | val = translation(raw_val) 344 | setattr(self, name, val) 345 | i = i + 1 346 | 347 | 348 | class RDGroupState: 349 | def __init__(self, group): 350 | self.group = group 351 | self.register_properties = { 352 | 'setting_volts': { 353 | 'description': 'Voltage setting', 354 | 'register': 0x50 + (0x04 * group), 355 | **_simple_int(100), 356 | }, 357 | 'setting_amps': { 358 | 'description': 'Amperage setting', 359 | 'register': 0x51 + (0x04 * group), 360 | **_simple_int(1000), 361 | }, 362 | 'cutoff_volts': { 363 | 'description': 'Volts cutoff', 364 | 'register': 0x52 + (0x04 * group), 365 | **_simple_int(100), 366 | }, 367 | 'cutoff_amps': { 368 | 'description': 'Amps cutoff', 369 | 'register': 0x53 + (0x04 * group), 370 | **_simple_int(1000), 371 | }, 372 | } 373 | 374 | for name in self.register_properties: 375 | setattr(self, name, self.register_properties[name]['from_int'](0)) 376 | 377 | def load(self, data, offset=0): 378 | pos_map = {v['register']: k for k, v in self.register_properties.items()} 379 | i = 0 380 | for raw_val in data: 381 | val_pos = offset + i 382 | if val_pos in pos_map: 383 | name = pos_map[val_pos] 384 | translation = self.register_properties[name]['from_int'] 385 | val = translation(raw_val) 386 | setattr(self, name, val) 387 | i = i + 1 388 | -------------------------------------------------------------------------------- /rdserial/dps/tool.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import logging 20 | import json 21 | import datetime 22 | import time 23 | import statistics 24 | 25 | import rdserial.dps 26 | import rdserial.modbus 27 | 28 | 29 | dps_supported_devices = ['dps', 'dps3005', 'dps5005', 'dps5015', 'dps5020', 'dps8005', 'dph5005'] 30 | rd_supported_devices = ['rd', 'rd6006'] 31 | supported_devices = dps_supported_devices + rd_supported_devices 32 | 33 | 34 | class Tool: 35 | def __init__(self, parent=None): 36 | self.trends = {} 37 | if parent is not None: 38 | self.args = parent.args 39 | self.socket = parent.socket 40 | 41 | def trend_s(self, name, value): 42 | if not self.args.watch: 43 | return '' 44 | 45 | if name in self.trends: 46 | trend = statistics.mean(self.trends[name]) 47 | self.trends[name] = self.trends[name][1:] + [value] 48 | if value > trend: 49 | return '\u2197' 50 | elif value < trend: 51 | return '\u2198' 52 | else: 53 | return ' ' 54 | else: 55 | self.trends[name] = [value for x in range(self.args.trend_points)] 56 | return ' ' 57 | 58 | def send_commands(self): 59 | register_commands = {} 60 | 61 | device_state = self.device_state_class() 62 | command_map = ( 63 | ('set_volts', 'setting_volts'), 64 | ('set_amps', 'setting_amps'), 65 | ('set_output_state', 'output_state'), 66 | ('set_key_lock', 'key_lock'), 67 | ('set_brightness', 'brightness'), 68 | ('load_group', 'group_loader'), 69 | ) 70 | group_command_map = ( 71 | ('set_group_volts', 'setting_volts'), 72 | ('set_group_amps', 'setting_amps'), 73 | ('set_group_cutoff_volts', 'cutoff_volts'), 74 | ('set_group_cutoff_amps', 'cutoff_amps'), 75 | ) 76 | if self.device_mode == 'dps': 77 | group_command_map += ( 78 | ('set_group_cutoff_watts', 'cutoff_watts'), 79 | ('set_group_brightness', 'brightness'), 80 | ('set_group_maintain_output', 'maintain_output'), 81 | ('set_group_poweron_output', 'poweron_output'), 82 | ) 83 | 84 | for arg_name, register_name in command_map: 85 | arg_val = getattr(self.args, arg_name) 86 | if arg_val is None: 87 | continue 88 | translation = device_state.register_properties[register_name]['to_int'] 89 | description = device_state.register_properties[register_name]['description'] 90 | register_num = device_state.register_properties[register_name]['register'] 91 | register_val = translation(arg_val) 92 | logging.info('Setting "{}" to {}'.format( 93 | description, arg_val 94 | )) 95 | logging.debug('{} "{}" (register {}): {} ({})'.format( 96 | register_name, description, register_num, arg_val, register_val 97 | )) 98 | register_commands[register_num] = register_val 99 | 100 | if getattr(self.args, 'set_clock'): 101 | logging.info('Setting device clock') 102 | now = datetime.datetime.now() 103 | for name in ('year', 'month', 'day', 'hour', 'minute', 'second'): 104 | register_name = 'datetime_{}'.format(name) 105 | val = getattr(now, name) 106 | translation = device_state.register_properties[register_name]['to_int'] 107 | description = device_state.register_properties[register_name]['description'] 108 | register_num = device_state.register_properties[register_name]['register'] 109 | register_val = translation(val) 110 | logging.debug('{} "{}" (register {}): {} ({})'.format( 111 | register_name, description, register_num, val, register_val 112 | )) 113 | register_commands[register_num] = register_val 114 | 115 | if self.args.all_groups: 116 | groups = range(10) 117 | elif self.args.group is not None: 118 | groups = self.args.group 119 | else: 120 | groups = [] 121 | for group in groups: 122 | device_group_state = self.device_group_state_class(group) 123 | 124 | for arg_name, register_name in group_command_map: 125 | arg_val = getattr(self.args, arg_name) 126 | if arg_val is None: 127 | continue 128 | translation = device_group_state.register_properties[register_name]['to_int'] 129 | description = device_group_state.register_properties[register_name]['description'] 130 | register_num = device_group_state.register_properties[register_name]['register'] 131 | register_val = translation(arg_val) 132 | logging.info('Setting group {} "{}" to {}'.format( 133 | group, description, arg_val 134 | )) 135 | logging.debug('Group {} {} "{}" (register {}): {} ({})'.format( 136 | group, register_name, description, register_num, arg_val, register_val 137 | )) 138 | register_commands[register_num] = register_val 139 | 140 | if len(register_commands) > 0: 141 | logging.info('') 142 | 143 | # Optimize into a set of minimal register writes 144 | register_commands_opt = {} 145 | for register in sorted(register_commands.keys()): 146 | found_opt = False 147 | for g in register_commands_opt: 148 | if (register == g + len(register_commands_opt[g])) and (len(register_commands_opt[g]) < 32): 149 | register_commands_opt[g].append(register_commands[register]) 150 | found_opt = True 151 | break 152 | if not found_opt: 153 | register_commands_opt[register] = [register_commands[register]] 154 | for register_base in register_commands_opt: 155 | logging.debug('Writing {} register(s) ({}) at base {}'.format( 156 | len(register_commands_opt[register_base]), 157 | register_commands_opt[register_base], 158 | register_base, 159 | )) 160 | self.modbus_client.write_registers( 161 | register_base, register_commands_opt[register_base], unit=self.args.modbus_unit, 162 | ) 163 | 164 | def print_human(self, device_state): 165 | protection_map = { 166 | rdserial.dps.PROTECTION_GOOD: 'good', 167 | rdserial.dps.PROTECTION_OV: 'over-voltage', 168 | rdserial.dps.PROTECTION_OC: 'over-current', 169 | rdserial.dps.PROTECTION_OP: 'over-power', 170 | } 171 | print('Setting: {:5.02f}V, {:6.03f}A ({})'.format( 172 | device_state.setting_volts, 173 | device_state.setting_amps, 174 | ('CC' if device_state.constant_current else 'CV'), 175 | )) 176 | print('Output {:5}: {:5.02f}V{}, {:5.02f}A{}, {:6.02f}W{}'.format( 177 | ('(on)' if device_state.output_state else '(off)'), 178 | device_state.volts, 179 | self.trend_s('volts', device_state.volts), 180 | device_state.amps, 181 | self.trend_s('amps', device_state.amps), 182 | device_state.watts, 183 | self.trend_s('watts', device_state.watts), 184 | )) 185 | print('Input: {:5.02f}V{}, protection: {}'.format( 186 | device_state.input_volts, 187 | self.trend_s('input_volts', device_state.input_volts), 188 | protection_map[device_state.protection], 189 | )) 190 | print('Brightness: {}/5, key lock: {}'.format( 191 | device_state.brightness, 192 | 'on' if device_state.key_lock else 'off', 193 | )) 194 | if hasattr(device_state, 'serial'): 195 | print('Model: {}, firmware: {}, serial: {}'.format(device_state.model, device_state.firmware, device_state.serial)) 196 | else: 197 | print('Model: {}, firmware: {}'.format(device_state.model, device_state.firmware)) 198 | print('Collection time: {}'.format(device_state.collection_time)) 199 | if len(device_state.groups) > 0: 200 | print() 201 | for group, device_group_state in sorted(device_state.groups.items()): 202 | print('Group {}:'.format(group)) 203 | print(' Setting: {:5.02f}V, {:6.03f}A'.format(device_group_state.setting_volts, device_group_state.setting_amps)) 204 | if hasattr(device_group_state, 'cutoff_watts'): 205 | print(' Cutoff: {:5.02f}V, {:6.03f}A, {:5.01f}W'.format( 206 | device_group_state.cutoff_volts, 207 | device_group_state.cutoff_amps, 208 | device_group_state.cutoff_watts, 209 | )) 210 | else: 211 | print(' Cutoff: {:5.02f}V, {:6.03f}A'.format( 212 | device_group_state.cutoff_volts, 213 | device_group_state.cutoff_amps, 214 | )) 215 | if hasattr(device_group_state, 'brightness'): 216 | print(' Brightness: {}/5'.format(device_group_state.brightness)) 217 | if hasattr(device_group_state, 'maintain_output'): 218 | print(' Maintain output state: {}'.format(device_group_state.maintain_output)) 219 | if hasattr(device_group_state, 'poweron_output'): 220 | print(' Output on power-on: {}'.format(device_group_state.poweron_output)) 221 | 222 | def print_json(self, device_state): 223 | out = {x: getattr(device_state, x) for x in device_state.register_properties} 224 | out['collection_time'] = (device_state.collection_time - datetime.datetime.fromtimestamp(0)).total_seconds() 225 | out['groups'] = {} 226 | for group, device_group_state in device_state.groups.items(): 227 | out['groups'][group] = {x: getattr(device_group_state, x) for x in device_group_state.register_properties} 228 | print(json.dumps(out, sort_keys=True)) 229 | 230 | def assemble_device_state(self): 231 | device_state = self.device_state_class() 232 | registers_length = (85 if self.device_mode == 'rd' else 13) 233 | registers = self.modbus_client.read_registers( 234 | 0x00, registers_length, unit=self.args.modbus_unit, 235 | ) 236 | device_state.load(registers) 237 | 238 | if self.args.all_groups: 239 | groups = range(10) 240 | elif self.args.group is not None: 241 | groups = self.args.group 242 | else: 243 | groups = [] 244 | for group in groups: 245 | register_offset = (0x04 if self.device_mode == 'rd' else 0x10) 246 | device_group_state = self.device_group_state_class(group) 247 | registers = self.modbus_client.read_registers( 248 | 0x50 + (register_offset * group), 249 | len(device_group_state.register_properties), 250 | unit=self.args.modbus_unit, 251 | ) 252 | device_group_state.load(registers, offset=(0x50 + (register_offset * group))) 253 | device_state.groups[group] = device_group_state 254 | 255 | return device_state 256 | 257 | def loop(self): 258 | while True: 259 | try: 260 | device_state = self.assemble_device_state() 261 | if self.args.json: 262 | self.print_json(device_state) 263 | else: 264 | self.print_human(device_state) 265 | except KeyboardInterrupt: 266 | raise 267 | except Exception: 268 | if self.args.watch: 269 | logging.exception('An exception has occurred') 270 | else: 271 | raise 272 | if self.args.watch: 273 | if not self.args.json: 274 | print() 275 | time.sleep(self.args.watch_seconds) 276 | else: 277 | return 278 | 279 | def main(self): 280 | if self.args.device in rd_supported_devices: 281 | self.device_mode = 'rd' 282 | self.device_state_class = rdserial.dps.RDDeviceState 283 | self.device_group_state_class = rdserial.dps.RDGroupState 284 | else: 285 | self.device_mode = 'dps' 286 | self.device_state_class = rdserial.dps.DPSDeviceState 287 | self.device_group_state_class = rdserial.dps.DPSGroupState 288 | self.modbus_client = rdserial.modbus.RTUClient( 289 | self.socket, 290 | baudrate=self.args.baud, 291 | ) 292 | try: 293 | self.send_commands() 294 | self.loop() 295 | except KeyboardInterrupt: 296 | pass 297 | -------------------------------------------------------------------------------- /rdserial/modbus/__init__.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | # Note that this is a massively simplified Modbus RTU client, 20 | # and is not suitable for general Modbus use. 21 | 22 | import time 23 | import struct 24 | import logging 25 | 26 | 27 | def modbus_crc(data): 28 | lookup_table = ( 29 | 0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 30 | 0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440, 31 | 0xcc01, 0x0cc0, 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40, 32 | 0x0a00, 0xcac1, 0xcb81, 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841, 33 | 0xd801, 0x18c0, 0x1980, 0xd941, 0x1b00, 0xdbc1, 0xda81, 0x1a40, 34 | 0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 0x1dc0, 0x1c80, 0xdc41, 35 | 0x1400, 0xd4c1, 0xd581, 0x1540, 0xd701, 0x17c0, 0x1680, 0xd641, 36 | 0xd201, 0x12c0, 0x1380, 0xd341, 0x1100, 0xd1c1, 0xd081, 0x1040, 37 | 0xf001, 0x30c0, 0x3180, 0xf141, 0x3300, 0xf3c1, 0xf281, 0x3240, 38 | 0x3600, 0xf6c1, 0xf781, 0x3740, 0xf501, 0x35c0, 0x3480, 0xf441, 39 | 0x3c00, 0xfcc1, 0xfd81, 0x3d40, 0xff01, 0x3fc0, 0x3e80, 0xfe41, 40 | 0xfa01, 0x3ac0, 0x3b80, 0xfb41, 0x3900, 0xf9c1, 0xf881, 0x3840, 41 | 0x2800, 0xe8c1, 0xe981, 0x2940, 0xeb01, 0x2bc0, 0x2a80, 0xea41, 42 | 0xee01, 0x2ec0, 0x2f80, 0xef41, 0x2d00, 0xedc1, 0xec81, 0x2c40, 43 | 0xe401, 0x24c0, 0x2580, 0xe541, 0x2700, 0xe7c1, 0xe681, 0x2640, 44 | 0x2200, 0xe2c1, 0xe381, 0x2340, 0xe101, 0x21c0, 0x2080, 0xe041, 45 | 0xa001, 0x60c0, 0x6180, 0xa141, 0x6300, 0xa3c1, 0xa281, 0x6240, 46 | 0x6600, 0xa6c1, 0xa781, 0x6740, 0xa501, 0x65c0, 0x6480, 0xa441, 47 | 0x6c00, 0xacc1, 0xad81, 0x6d40, 0xaf01, 0x6fc0, 0x6e80, 0xae41, 48 | 0xaa01, 0x6ac0, 0x6b80, 0xab41, 0x6900, 0xa9c1, 0xa881, 0x6840, 49 | 0x7800, 0xb8c1, 0xb981, 0x7940, 0xbb01, 0x7bc0, 0x7a80, 0xba41, 50 | 0xbe01, 0x7ec0, 0x7f80, 0xbf41, 0x7d00, 0xbdc1, 0xbc81, 0x7c40, 51 | 0xb401, 0x74c0, 0x7580, 0xb541, 0x7700, 0xb7c1, 0xb681, 0x7640, 52 | 0x7200, 0xb2c1, 0xb381, 0x7340, 0xb101, 0x71c0, 0x7080, 0xb041, 53 | 0x5000, 0x90c1, 0x9181, 0x5140, 0x9301, 0x53c0, 0x5280, 0x9241, 54 | 0x9601, 0x56c0, 0x5780, 0x9741, 0x5500, 0x95c1, 0x9481, 0x5440, 55 | 0x9c01, 0x5cc0, 0x5d80, 0x9d41, 0x5f00, 0x9fc1, 0x9e81, 0x5e40, 56 | 0x5a00, 0x9ac1, 0x9b81, 0x5b40, 0x9901, 0x59c0, 0x5880, 0x9841, 57 | 0x8801, 0x48c0, 0x4980, 0x8941, 0x4b00, 0x8bc1, 0x8a81, 0x4a40, 58 | 0x4e00, 0x8ec1, 0x8f81, 0x4f40, 0x8d01, 0x4dc0, 0x4c80, 0x8c41, 59 | 0x4400, 0x84c1, 0x8581, 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641, 60 | 0x8201, 0x42c0, 0x4380, 0x8341, 0x4100, 0x81c1, 0x8081, 0x4040, 61 | ) 62 | 63 | crc = 0xffff 64 | for b in data: 65 | n = b ^ crc 66 | crc >>= 8 67 | crc ^= lookup_table[n % 256] 68 | return crc 69 | 70 | 71 | class RTUClient: 72 | def __init__(self, socket, baudrate): 73 | self.socket = socket 74 | self._last_frame_end = time.time() 75 | if baudrate > 19200: 76 | self._silent_interval = 1.75/1000 77 | else: 78 | self._silent_interval = 3.5 * (1 + 8 + 2) / baudrate 79 | 80 | def read_registers(self, base, length, unit=1): 81 | request = struct.pack('>B', unit) + \ 82 | struct.pack('>B', 0x03) + \ 83 | struct.pack('>H', base) + \ 84 | struct.pack('>H', length) 85 | request += struct.pack('B', response[0:1])[0] == unit) 93 | assert(struct.unpack('>B', response[1:2])[0] == 0x03) 94 | assert(struct.unpack('>B', response[2:3])[0] == (length * 2)) 95 | 96 | registers = [] 97 | for i in range(length): 98 | pos = 3 + (i * 2) 99 | val = struct.unpack('>H', response[pos:pos+2])[0] 100 | logging.debug('Register 0x{:02x}: {}'.format(pos, val)) 101 | registers.append(val) 102 | return registers 103 | 104 | def write_register(self, register, value, unit=1): 105 | request = struct.pack('>B', unit) + \ 106 | struct.pack('>B', 0x06) + \ 107 | struct.pack('>H', register) + \ 108 | struct.pack('>H', value) 109 | request += struct.pack('B', unit) + \ 117 | struct.pack('>B', 0x10) + \ 118 | struct.pack('>H', register) + \ 119 | struct.pack('>H', len(values)) + \ 120 | struct.pack('>B', len(values) * 2) 121 | for value in values: 122 | request += struct.pack('>H', value) 123 | request += struct.pack('B', response[0:1])[0] == unit) 129 | assert(struct.unpack('>B', response[1:2])[0] == 0x10) 130 | assert(struct.unpack('>H', response[2:4])[0] == register) 131 | assert(struct.unpack('>H', response[4:6])[0] == len(values)) 132 | 133 | def send(self, data): 134 | ts = time.time() 135 | if ts < self._last_frame_end + self._silent_interval: 136 | to_sleep = self._last_frame_end + self._silent_interval - ts 137 | logging.debug('Sleeping {} for 3.5 char ({}) quiet period'.format( 138 | to_sleep, 139 | self._silent_interval, 140 | )) 141 | time.sleep(to_sleep) 142 | 143 | result = self.socket.send(data) 144 | self._last_frame_end = time.time() 145 | return result 146 | 147 | def recv(self, size): 148 | result = self.socket.recv(size) 149 | self._last_frame_end = time.time() 150 | return result 151 | -------------------------------------------------------------------------------- /rdserial/tool.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import argparse 20 | import sys 21 | import os 22 | import logging 23 | import time 24 | 25 | from rdserial import __version__ 26 | import rdserial.device 27 | import rdserial.um.tool 28 | import rdserial.dps.tool 29 | 30 | 31 | def parse_args(argv=None): 32 | """Parse user arguments.""" 33 | if argv is None: 34 | argv = sys.argv 35 | 36 | def loose_bool(val): 37 | return val.lower() in ('on', 'true', 'yes') 38 | 39 | def validate_set_record_threshold(string): 40 | val = float(string) 41 | if val not in [x / 100 for x in range(31)]: 42 | raise argparse.ArgumentTypeError('Must be between 0.00 and 0.30, in 0.01 steps') 43 | return val 44 | 45 | parser = argparse.ArgumentParser( 46 | description='rdserialtool ({})'.format(__version__), 47 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 48 | prog=os.path.basename(argv[0]), 49 | ) 50 | 51 | parser.add_argument( 52 | '--version', '-V', action='version', 53 | version=__version__, 54 | help='Report the program version', 55 | ) 56 | 57 | parser.add_argument( 58 | '--quiet', '-q', action='store_true', 59 | help='Suppress human-readable stderr information', 60 | ) 61 | parser.add_argument( 62 | '--debug', action='store_true', 63 | help='Print extra debugging information.', 64 | ) 65 | 66 | supported_devices = [] 67 | supported_devices += rdserial.um.tool.supported_devices 68 | supported_devices += rdserial.dps.tool.supported_devices 69 | parser.add_argument( 70 | '--device', '-d', required=True, 71 | choices=sorted(supported_devices), 72 | help='Device type', 73 | ) 74 | 75 | device_group = parser.add_mutually_exclusive_group(required=True) 76 | device_group.add_argument( 77 | '--bluetooth-address', '-b', 78 | help='Bluetooth EUI-48 address of the device', 79 | ) 80 | device_group.add_argument( 81 | '--serial-device', '-s', 82 | help='Serial filename (e.g. /dev/rfcomm0) of the device', 83 | ) 84 | 85 | parser.add_argument( 86 | '--bluetooth-port', type=int, default=1, 87 | help='Bluetooth RFCOMM port number', 88 | ) 89 | parser.add_argument( 90 | '--baud', type=int, default=9600, 91 | help='Serial port baud rate', 92 | ) 93 | parser.add_argument( 94 | '--connect-delay', type=float, default=0.3, 95 | help='Seconds to wait after connecting to the serial port', 96 | ) 97 | parser.add_argument( 98 | '--json', action='store_true', 99 | help='Output JSON data', 100 | ) 101 | parser.add_argument( 102 | '--watch', action='store_true', 103 | help='Repeat data collection until cancelled', 104 | ) 105 | parser.add_argument( 106 | '--watch-seconds', type=float, default=2.0, 107 | help='Number of seconds between collections in watch mode', 108 | ) 109 | parser.add_argument( 110 | '--trend-points', type=int, default=5, 111 | help='Number of points to remember for determining a trend in watch mode', 112 | ) 113 | 114 | parser_group_dps = parser.add_argument_group( 115 | 'DPS/RD-related arguments' 116 | ) 117 | 118 | parser_group_dps.add_argument( 119 | '--modbus-unit', type=int, default=1, 120 | help='Modbus unit number', 121 | ) 122 | parser_group_dps.add_argument( 123 | '--group', type=int, action='append', 124 | help='Display/set selected group(s)', 125 | ) 126 | parser_group_dps.add_argument( 127 | '--all-groups', action='store_true', 128 | help='Display/set all groups', 129 | ) 130 | 131 | parser_group_dps.add_argument( 132 | '--set-volts', type=float, default=None, 133 | help='Set voltage setting', 134 | ) 135 | parser_group_dps.add_argument( 136 | '--set-amps', type=float, default=None, 137 | help='Set current setting', 138 | ) 139 | parser_group_dps.add_argument( 140 | '--set-clock', action='store_true', 141 | help='Set clock to current time [RD]', 142 | ) 143 | 144 | onoff_group = parser_group_dps.add_mutually_exclusive_group(required=False) 145 | onoff_group.add_argument( 146 | '--set-output-state', type=loose_bool, dest='set_output_state', default=None, 147 | help='Set output on/off', 148 | ) 149 | onoff_group.add_argument( 150 | '--on', action='store_true', dest='set_output_state', 151 | help='Set output on', 152 | ) 153 | onoff_group.add_argument( 154 | '--off', action='store_false', dest='set_output_state', 155 | help='Set output off', 156 | ) 157 | 158 | parser_group_dps.add_argument( 159 | '--set-key-lock', type=loose_bool, default=None, 160 | help='Set key lock on/off', 161 | ) 162 | parser_group_dps.add_argument( 163 | '--set-brightness', type=int, choices=range(6), default=None, 164 | help='Set screen brightness', 165 | ) 166 | parser_group_dps.add_argument( 167 | '--load-group', type=int, choices=range(10), default=None, 168 | help='Load group settings into group 0', 169 | ) 170 | 171 | parser_group_dps.add_argument( 172 | '--set-group-volts', type=float, default=None, 173 | help='Set group voltage setting', 174 | ) 175 | parser_group_dps.add_argument( 176 | '--set-group-amps', type=float, default=None, 177 | help='Set group current setting', 178 | ) 179 | parser_group_dps.add_argument( 180 | '--set-group-cutoff-volts', type=float, default=None, 181 | help='Set group cutoff volts', 182 | ) 183 | parser_group_dps.add_argument( 184 | '--set-group-cutoff-amps', type=float, default=None, 185 | help='Set group cutoff amps', 186 | ) 187 | parser_group_dps.add_argument( 188 | '--set-group-cutoff-watts', type=float, default=None, 189 | help='Set group cutoff watts', 190 | ) 191 | parser_group_dps.add_argument( 192 | '--set-group-brightness', type=int, choices=range(6), default=None, 193 | help='Set group screen brightness', 194 | ) 195 | parser_group_dps.add_argument( 196 | '--set-group-maintain-output', type=loose_bool, default=None, 197 | help='Set group maintain output state during group change', 198 | ) 199 | parser_group_dps.add_argument( 200 | '--set-group-poweron-output', type=loose_bool, default=None, 201 | help='Set group enable output on power-on', 202 | ) 203 | 204 | parser_group_um = parser.add_argument_group( 205 | 'UM-related arguments' 206 | ) 207 | 208 | parser_group_um.add_argument( 209 | '--next-screen', action='store_true', 210 | help='Go to the next screen on the display', 211 | ) 212 | parser_group_um.add_argument( 213 | '--rotate-screen', action='store_true', 214 | help='Rotate the screen 90 degrees clockwise', 215 | ) 216 | parser_group_um.add_argument( 217 | '--clear-data-group', action='store_true', 218 | help='Clear the current data group', 219 | ) 220 | parser_group_um.add_argument( 221 | '--set-record-threshold', type=validate_set_record_threshold, default=None, 222 | help='Set the recording threshold, 0.00-0.30 inclusive', 223 | ) 224 | parser_group_um.add_argument( 225 | '--set-screen-brightness', type=int, choices=range(6), default=None, 226 | help='Set the screen brightness', 227 | ) 228 | parser_group_um.add_argument( 229 | '--set-screen-timeout', type=int, choices=range(10), default=None, 230 | help='Set the screen timeout', 231 | ) 232 | 233 | parser_group_um.add_argument( 234 | '--previous-screen', action='store_true', 235 | help='Go to the previous screen on the display', 236 | ) 237 | parser_group_um.add_argument( 238 | '--set-data-group', type=int, choices=range(10), default=None, 239 | help='Set the selected data group', 240 | ) 241 | 242 | parser_group_um.add_argument( 243 | '--next-data-group', action='store_true', 244 | help='Change to the next data group', 245 | ) 246 | 247 | args = parser.parse_args(args=argv[1:]) 248 | 249 | return args 250 | 251 | 252 | class RDSerialTool: 253 | def setup_logging(self): 254 | logging_format = '%(message)s' 255 | if self.args.debug: 256 | logging_level = logging.DEBUG 257 | logging_format = '%(asctime)s %(levelname)s: %(message)s' 258 | elif self.args.quiet: 259 | logging_level = logging.ERROR 260 | else: 261 | logging_level = logging.INFO 262 | logging.basicConfig( 263 | format=logging_format, 264 | level=logging_level, 265 | ) 266 | 267 | def main(self): 268 | self.args = parse_args() 269 | self.setup_logging() 270 | 271 | logging.info('rdserialtool {}'.format(__version__)) 272 | logging.info('Copyright (C) 2019 Ryan Finnie') 273 | logging.info('') 274 | 275 | if self.args.serial_device: 276 | logging.info('Connecting to {} {}'.format(self.args.device.upper(), self.args.serial_device)) 277 | self.socket = rdserial.device.Serial( 278 | self.args.serial_device, 279 | baudrate=self.args.baud, 280 | ) 281 | else: 282 | logging.info('Connecting to {} {}'.format(self.args.device.upper(), self.args.bluetooth_address)) 283 | self.socket = rdserial.device.Bluetooth( 284 | self.args.bluetooth_address, 285 | port=self.args.bluetooth_port, 286 | ) 287 | self.socket.connect() 288 | logging.info('Connection established') 289 | logging.info('') 290 | time.sleep(self.args.connect_delay) 291 | 292 | if self.args.device in rdserial.um.tool.supported_devices: 293 | tool = rdserial.um.tool.Tool(self) 294 | elif self.args.device in rdserial.dps.tool.supported_devices: 295 | tool = rdserial.dps.tool.Tool(self) 296 | ret = tool.main() 297 | 298 | self.socket.close() 299 | return ret 300 | 301 | 302 | def main(): 303 | return RDSerialTool().main() 304 | 305 | 306 | if __name__ == '__main__': 307 | sys.exit(main()) 308 | -------------------------------------------------------------------------------- /rdserial/um/__init__.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import struct 20 | import datetime 21 | import logging 22 | 23 | CHARGING_UNKNOWN = 0 24 | CHARGING_QC2 = 1 25 | CHARGING_QC3 = 2 26 | CHARGING_APP2_4A = 3 27 | CHARGING_APP2_1A = 4 28 | CHARGING_APP1_0A = 5 29 | CHARGING_APP0_5A = 6 30 | CHARGING_DCP1_5A = 7 31 | CHARGING_SAMSUNG = 8 32 | 33 | 34 | class DataGroup: 35 | group = 0 36 | amp_hours = 0 37 | watt_hours = 0 38 | 39 | def __repr__(self): 40 | return (''.format( 41 | self.group, 42 | self.amp_hours, 43 | self.watt_hours, 44 | )) 45 | 46 | def __init__(self, group=0): 47 | self.group = group 48 | 49 | 50 | class Response: 51 | def __repr__(self): 52 | return (''.format( 53 | self.device_type, 54 | self.collection_time, 55 | self.volts, 56 | self.amps, 57 | )) 58 | 59 | def __init__(self, data=None, collection_time=None, device_type='UM24C'): 60 | self.device_type = device_type 61 | if device_type == 'UM25C': 62 | self.device_multiplier = 10 63 | else: 64 | self.device_multiplier = 1 65 | 66 | self.field_properties = { 67 | 'start': { 68 | 'description': 'Start bytes', 69 | 'position': 0, 70 | 'length': 2, 71 | 'from_int': lambda x: x, 72 | 'to_int': lambda x: int(x), 73 | }, 74 | 'volts': { 75 | 'description': 'Volts', 76 | 'position': 2, 77 | 'length': 2, 78 | 'from_int': lambda x: x / (100 * self.device_multiplier), 79 | 'to_int': lambda x: int(x * (100 * self.device_multiplier)), 80 | }, 81 | 'amps': { 82 | 'description': 'Amps', 83 | 'position': 4, 84 | 'length': 2, 85 | 'from_int': lambda x: x / (1000 * self.device_multiplier), 86 | 'to_int': lambda x: int(x * (1000 * self.device_multiplier)), 87 | }, 88 | 'watts': { 89 | 'description': 'Watts', 90 | 'position': 6, 91 | 'length': 4, 92 | 'from_int': lambda x: x / 1000, 93 | 'to_int': lambda x: int(x * 1000), 94 | }, 95 | 'temp_c': { 96 | 'description': 'Temperature (Celsius)', 97 | 'position': 10, 98 | 'length': 2, 99 | 'from_int': lambda x: x, 100 | 'to_int': lambda x: int(x), 101 | }, 102 | 'temp_f': { 103 | 'description': 'Temperature (Fahrenheit)', 104 | 'position': 12, 105 | 'length': 2, 106 | 'from_int': lambda x: x, 107 | 'to_int': lambda x: int(x), 108 | }, 109 | 'data_group_selected': { 110 | 'description': 'Currently selected data group', 111 | 'position': 14, 112 | 'length': 2, 113 | 'from_int': lambda x: x, 114 | 'to_int': lambda x: int(x), 115 | }, 116 | 'data_line_positive_volts': { 117 | 'description': 'Positive data line volts', 118 | 'position': 96, 119 | 'length': 2, 120 | 'from_int': lambda x: x / 100, 121 | 'to_int': lambda x: int(x * 100), 122 | }, 123 | 'data_line_negative_volts': { 124 | 'description': 'Negative data line volts', 125 | 'position': 98, 126 | 'length': 2, 127 | 'from_int': lambda x: x / 100, 128 | 'to_int': lambda x: int(x * 100), 129 | }, 130 | 'charging_mode': { 131 | 'description': 'Charging mode', 132 | 'position': 100, 133 | 'length': 2, 134 | 'from_int': lambda x: x, 135 | 'to_int': lambda x: int(x), 136 | }, 137 | 'record_amphours': { 138 | 'description': 'Recorded amp-hours', 139 | 'position': 102, 140 | 'length': 4, 141 | 'from_int': lambda x: x / 1000, 142 | 'to_int': lambda x: int(x * 1000), 143 | }, 144 | 'record_watthours': { 145 | 'description': 'Recorded watt-hours', 146 | 'position': 106, 147 | 'length': 4, 148 | 'from_int': lambda x: x / 1000, 149 | 'to_int': lambda x: int(x * 1000), 150 | }, 151 | 'record_threshold': { 152 | 'description': 'Recording threshold (Amps)', 153 | 'position': 110, 154 | 'length': 2, 155 | 'from_int': lambda x: x / 100, 156 | 'to_int': lambda x: int(x * 100), 157 | }, 158 | 'record_seconds': { 159 | 'description': 'Recorded time (Seconds)', 160 | 'position': 112, 161 | 'length': 4, 162 | 'from_int': lambda x: x, 163 | 'to_int': lambda x: int(x), 164 | }, 165 | 'recording': { 166 | 'description': 'Recording', 167 | 'position': 116, 168 | 'length': 2, 169 | 'from_int': lambda x: bool(x), 170 | 'to_int': lambda x: int(x), 171 | }, 172 | 'screen_timeout': { 173 | 'description': 'Screen timeout (Minutes)', 174 | 'position': 118, 175 | 'length': 2, 176 | 'from_int': lambda x: x, 177 | 'to_int': lambda x: int(x), 178 | }, 179 | 'screen_brightness': { 180 | 'description': 'Screen brightness', 181 | 'position': 120, 182 | 'length': 2, 183 | 'from_int': lambda x: x, 184 | 'to_int': lambda x: int(x), 185 | }, 186 | 'resistance': { 187 | 'description': 'Resistance (Ohms)', 188 | 'position': 122, 189 | 'length': 4, 190 | 'from_int': lambda x: x / 10, 191 | 'to_int': lambda x: int(x * 10), 192 | }, 193 | 'screen_selected': { 194 | 'description': 'Currently selected screen', 195 | 'position': 126, 196 | 'length': 2, 197 | 'from_int': lambda x: x, 198 | 'to_int': lambda x: int(x), 199 | }, 200 | 'end': { 201 | 'description': 'End bytes', 202 | 'position': 128, 203 | 'length': 2, 204 | 'from_int': lambda x: x, 205 | 'to_int': lambda x: int(x), 206 | }, 207 | } 208 | 209 | if collection_time is None: 210 | collection_time = datetime.datetime.now() 211 | self.collection_time = collection_time 212 | for name in self.field_properties: 213 | setattr(self, name, 0) 214 | self.data_groups = [DataGroup(x) for x in range(10)] 215 | 216 | if data: 217 | self.load(data) 218 | 219 | def dump(self): 220 | data = bytearray(130) 221 | for name in self.field_properties: 222 | pos = self.field_properties[name]['position'] 223 | pos_len = self.field_properties[name]['length'] 224 | if pos_len == 2: 225 | pack_format = '>H' 226 | elif pos_len == 4: 227 | pack_format = '>L' 228 | else: 229 | pack_format = 'B' 230 | conversion_dump = self.field_properties[name]['to_int'] 231 | data[pos:pos+pos_len] = struct.pack(pack_format, conversion_dump(getattr(self, name))) 232 | 233 | for data_group in self.data_groups: 234 | if (data_group.group > 9) or (data_group.group < 0): 235 | continue 236 | pos = 16 + (data_group.group * 8) 237 | data[pos:pos+4] = struct.pack('>L', int(data_group.amp_hours * 1000)) 238 | data[pos+4:pos+8] = struct.pack('>L', int(data_group.watt_hours * 1000)) 239 | return bytes(data) 240 | 241 | def load(self, data): 242 | if len(data) != 130: 243 | raise ValueError('Invalid data length', data) 244 | logging.debug('Start: 0x{:02x}{:02x}, end: 0x{:02x}{:02x}'.format(data[0], data[1], data[128], data[129])) 245 | for name in self.field_properties: 246 | pos = self.field_properties[name]['position'] 247 | pos_len = self.field_properties[name]['length'] 248 | if pos_len == 2: 249 | pack_format = '>H' 250 | elif pos_len == 4: 251 | pack_format = '>L' 252 | else: 253 | pack_format = 'B' 254 | conversion_load = self.field_properties[name]['from_int'] 255 | val = conversion_load(struct.unpack(pack_format, data[pos:pos+pos_len])[0]) 256 | setattr(self, name, val) 257 | 258 | self.data_groups = [] 259 | for i in range(10): 260 | data_group = DataGroup(i) 261 | pos = 16 + (i * 8) 262 | data_group.amp_hours = struct.unpack('>L', data[pos:pos+4])[0] / 1000 263 | data_group.watt_hours = struct.unpack('>L', data[pos+4:pos+8])[0] / 1000 264 | self.data_groups.append(data_group) 265 | -------------------------------------------------------------------------------- /rdserial/um/tool.py: -------------------------------------------------------------------------------- 1 | # rdserialtool 2 | # Copyright (C) 2019 Ryan Finnie 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 17 | # 02110-1301, USA. 18 | 19 | import json 20 | import time 21 | import datetime 22 | import logging 23 | import statistics 24 | 25 | import rdserial.um 26 | 27 | 28 | supported_devices = ['um24c', 'um25c', 'um34c'] 29 | 30 | 31 | class Tool: 32 | def __init__(self, parent=None): 33 | self.trends = {} 34 | if parent is not None: 35 | self.args = parent.args 36 | self.socket = parent.socket 37 | 38 | def trend_s(self, name, value): 39 | if not self.args.watch: 40 | return '' 41 | 42 | if name in self.trends: 43 | trend = statistics.mean(self.trends[name]) 44 | self.trends[name] = self.trends[name][1:] + [value] 45 | if value > trend: 46 | return '\u2197' 47 | elif value < trend: 48 | return '\u2198' 49 | else: 50 | return ' ' 51 | else: 52 | self.trends[name] = [value for x in range(self.args.trend_points)] 53 | return ' ' 54 | 55 | def print_json(self, response): 56 | out = {x: getattr(response, x) for x in response.field_properties} 57 | out['data_groups'] = [{'amp_hours': x.amp_hours, 'watt_hours': x.watt_hours} for x in response.data_groups] 58 | out['collection_time'] = (response.collection_time - datetime.datetime.fromtimestamp(0)).total_seconds() 59 | print(json.dumps(out, sort_keys=True)) 60 | 61 | def print_human(self, response): 62 | logging.debug('DUMP: {}'.format(repr(response.dump()))) 63 | charging_map = { 64 | rdserial.um.CHARGING_UNKNOWN: 'Unknown / Normal', 65 | rdserial.um.CHARGING_QC2: 'Quick Charge 2.0', 66 | rdserial.um.CHARGING_QC3: 'Quick Charge 3.0', 67 | rdserial.um.CHARGING_APP2_4A: 'Apple 2.4A', 68 | rdserial.um.CHARGING_APP2_1A: 'Apple 2.1A', 69 | rdserial.um.CHARGING_APP1_0A: 'Apple 1.0A', 70 | rdserial.um.CHARGING_APP0_5A: 'Apple 0.5A', 71 | rdserial.um.CHARGING_DCP1_5A: 'DCP 1.5A', 72 | rdserial.um.CHARGING_SAMSUNG: 'Samsung', 73 | } 74 | if self.args.device == 'um25c': 75 | usb_format = 'USB: {:5.03f}V{}, {:6.04f}A{}, {:6.03f}W{}, {:6.01f}Ω{}' 76 | else: 77 | usb_format = 'USB: {:5.02f}V{}, {:6.03f}A{}, {:6.03f}W{}, {:6.01f}Ω{}' 78 | print(usb_format.format( 79 | response.volts, 80 | self.trend_s('volts', response.volts), 81 | response.amps, 82 | self.trend_s('amps', response.amps), 83 | response.watts, 84 | self.trend_s('watts', response.watts), 85 | response.resistance, 86 | self.trend_s('resistance', response.resistance), 87 | )) 88 | print('Data: {:5.02f}V(+){}, {:5.02f}V(-){}, charging mode: {}'.format( 89 | response.data_line_positive_volts, 90 | self.trend_s('data_line_positive_volts', response.data_line_positive_volts), 91 | response.data_line_negative_volts, 92 | self.trend_s('data_line_negative_volts', response.data_line_negative_volts), 93 | charging_map[response.charging_mode], 94 | )) 95 | print('Recording {:5}: {:8.03f}Ah{}, {:8.03f}Wh{}, {:6d}{} sec at >= {:4.02f}A'.format( 96 | '(on)' if response.recording else '(off)', 97 | response.record_amphours, 98 | self.trend_s('record_amphours', response.record_amphours), 99 | response.record_watthours, 100 | self.trend_s('record_watthours', response.record_watthours), 101 | response.record_seconds, 102 | self.trend_s('record_seconds', response.record_seconds), 103 | response.record_threshold, 104 | )) 105 | 106 | def make_dgpart(response, idx): 107 | data_group = response.data_groups[idx] 108 | return '{}{:d}: {:8.03f}Ah{}, {:8.03f}Wh{}'.format( 109 | '*' if data_group.group == response.data_group_selected else ' ', 110 | data_group.group, 111 | data_group.amp_hours, 112 | self.trend_s('dg_{}_amp_hours'.format(data_group.group), data_group.amp_hours), 113 | data_group.watt_hours, 114 | self.trend_s('dg_{}_watt_hours'.format(data_group.group), data_group.watt_hours), 115 | ) 116 | print('Data groups:') 117 | print(' {:32}{}'.format( 118 | make_dgpart(response, 0), 119 | make_dgpart(response, 5), 120 | )) 121 | print(' {:32}{}'.format( 122 | make_dgpart(response, 1), 123 | make_dgpart(response, 6), 124 | )) 125 | print(' {:32}{}'.format( 126 | make_dgpart(response, 2), 127 | make_dgpart(response, 7), 128 | )) 129 | print(' {:32}{}'.format( 130 | make_dgpart(response, 3), 131 | make_dgpart(response, 8), 132 | )) 133 | print(' {:32}{}'.format( 134 | make_dgpart(response, 4), 135 | make_dgpart(response, 9), 136 | )) 137 | 138 | print('{:>5s}, temperature: {:3d}C{} ({:3d}F{})'.format( 139 | self.args.device.upper(), 140 | response.temp_c, 141 | self.trend_s('temp_c', response.temp_c), 142 | response.temp_f, 143 | self.trend_s('temp_f', response.temp_f), 144 | )) 145 | print('Screen: {:d}/6, brightness: {:d}/5, timeout: {}'.format( 146 | response.screen_selected, 147 | response.screen_brightness, 148 | '{:d} min'.format(response.screen_timeout) if response.screen_timeout else 'off', 149 | )) 150 | if response.collection_time: 151 | print('Collection time: {}'.format(response.collection_time)) 152 | 153 | def send_commands(self): 154 | for arg, command_val in [ 155 | ('next_screen', b'\xf1'), 156 | ('rotate_screen', b'\xf2'), 157 | ('next_data_group', b'\xf3'), 158 | ('previous_screen', b'\xf3'), 159 | ('clear_data_group', b'\xf4'), 160 | ('set_data_group', lambda x: bytes([0xa0 + x])), 161 | ('set_record_threshold', lambda x: bytes([0xb0 + int(x * 100)])), 162 | ('set_screen_brightness', lambda x: bytes([0xd0 + x])), 163 | ('set_screen_timeout', lambda x: bytes([0xe0 + x])), 164 | ]: 165 | if not hasattr(self.args, arg): 166 | continue 167 | arg_val = getattr(self.args, arg) 168 | if (arg_val is None) or (arg_val is False): 169 | continue 170 | if type(command_val) != bytes: 171 | command_val = command_val(getattr(self.args, arg)) 172 | logging.info('Setting {} to {}'.format(arg, getattr(self.args, arg))) 173 | self.socket.send(command_val) 174 | # Sometimes you can send multiple commands quickly, but sometimes 175 | # it'll eat commands. Sleeping 0.5s between commands is safe. 176 | time.sleep(0.5) 177 | 178 | def loop(self): 179 | while True: 180 | try: 181 | self.socket.send(b'\xf0') 182 | if self.args.json: 183 | self.print_json(rdserial.um.Response( 184 | self.socket.recv(130), 185 | collection_time=datetime.datetime.now(), 186 | device_type=self.args.device.upper(), 187 | )) 188 | else: 189 | self.print_human(rdserial.um.Response( 190 | self.socket.recv(130), 191 | collection_time=datetime.datetime.now(), 192 | device_type=self.args.device.upper(), 193 | )) 194 | except KeyboardInterrupt: 195 | raise 196 | except Exception: 197 | if self.args.watch: 198 | logging.exception('An exception has occurred') 199 | else: 200 | raise 201 | if self.args.watch: 202 | if not self.args.json: 203 | print() 204 | time.sleep(self.args.watch_seconds) 205 | else: 206 | return 207 | 208 | def main(self): 209 | try: 210 | self.send_commands() 211 | self.loop() 212 | except KeyboardInterrupt: 213 | pass 214 | -------------------------------------------------------------------------------- /rdserialtool: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # rdserialtool 4 | # Copyright (C) 2019 Ryan Finnie 5 | # 6 | # This program is free software; you can redistribute it and/or 7 | # modify it under the terms of the GNU General Public License 8 | # as published by the Free Software Foundation; either version 2 9 | # of the License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program; if not, write to the Free Software 18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 19 | # 02110-1301, USA. 20 | 21 | if __name__ == '__main__': 22 | import sys 23 | import rdserial.tool 24 | sys.exit(rdserial.tool.main()) 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | from setuptools import setup, find_packages 6 | 7 | assert(sys.version_info > (3, 4)) 8 | 9 | 10 | def read(filename): 11 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 12 | 13 | 14 | __version__ = None 15 | with open(os.path.join(os.path.dirname(__file__), 'rdserial', '__init__.py')) as f: 16 | for line in f: 17 | if not line.startswith('__version__ = '): 18 | continue 19 | __version__ = eval(line.rsplit(None, 1)[-1]) 20 | break 21 | 22 | 23 | setup( 24 | name='rdserialtool', 25 | description='RDTech UM/DPS series device interface tool', 26 | long_description=read('README'), 27 | version=__version__, 28 | license='GPLv2+', 29 | platforms=['Unix'], 30 | author='Ryan Finnie', 31 | author_email='ryan@finnie.org', 32 | url='https://github.com/rfinnie/rdserialtool', 33 | download_url='https://github.com/rfinnie/rdserialtool', 34 | packages=find_packages(), 35 | classifiers=[ 36 | 'Development Status :: 4 - Beta', 37 | 'Intended Audience :: Science/Research', 38 | 'License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)', 39 | 'Programming Language :: Python :: 3 :: Only', 40 | 'Programming Language :: Python :: Implementation :: PyPy', 41 | 'Topic :: Scientific/Engineering :: Information Analysis', 42 | 'Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator', 43 | ], 44 | entry_points={ 45 | 'console_scripts': [ 46 | 'rdserialtool = rdserial.tool:main', 47 | ], 48 | }, 49 | test_suite='tests', 50 | ) 51 | --------------------------------------------------------------------------------