diff --git a/addons/hw_scale/controllers/main.py b/addons/hw_scale/controllers/main.py index 730b9bb9c68..1ed1bb57468 100644 --- a/addons/hw_scale/controllers/main.py +++ b/addons/hw_scale/controllers/main.py @@ -1,21 +1,20 @@ # -*- coding: utf-8 -*- import logging import os +import re import time -from os import listdir -from os.path import join -from threading import Thread, Lock -from select import select -from Queue import Queue, Empty -import openerp +from collections import namedtuple +from os import listdir +from threading import Thread, Lock + import openerp.addons.hw_proxy.controllers.main as hw_proxy from openerp import http -from openerp.http import request -from openerp.tools.translate import _ _logger = logging.getLogger(__name__) +DRIVER_NAME = 'scale' + try: import serial except ImportError: @@ -23,6 +22,99 @@ except ImportError: serial = None +def _toledo8217StatusParse(status): + """ Parse a scale's status, returning a `(weight, weight_info)` pair. """ + weight, weight_info = None, None + stat = ord(status[status.index('?') + 1]) + if stat == 0: + weight_info = 'ok' + else: + weight_info = [] + if stat & 1 : + weight_info.append('moving') + if stat & 1 << 1: + weight_info.append('over_capacity') + if stat & 1 << 2: + weight_info.append('negative') + weight = 0.0 + if stat & 1 << 3: + weight_info.append('outside_zero_capture_range') + if stat & 1 << 4: + weight_info.append('center_of_zero') + if stat & 1 << 5: + weight_info.append('net_weight') + return weight, weight_info + +ScaleProtocol = namedtuple( + 'ScaleProtocol', + "name baudrate bytesize stopbits parity timeout writeTimeout weightRegexp statusRegexp " + "statusParse commandTerminator commandDelay weightDelay newWeightDelay " + "weightCommand zeroCommand tareCommand clearCommand emptyAnswerValid autoResetWeight") + +# 8217 Mettler-Toledo (Weight-only) Protocol, as described in the scale's Service Manual. +# e.g. here: https://www.manualslib.com/manual/861274/Mettler-Toledo-Viva.html?page=51#manual +# Our recommended scale, the Mettler-Toledo "Ariva-S", supports this protocol on +# both the USB and RS232 ports, it can be configured in the setup menu as protocol option 3. +# We use the default serial protocol settings, the scale's settings can be configured in the +# scale's menu anyway. +Toledo8217Protocol = ScaleProtocol( + name='Toledo 8217', + baudrate=9600, + bytesize=serial.SEVENBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_EVEN, + timeout=1, + writeTimeout=1, + weightRegexp="\x02\\s*([0-9.]+)N?\\r", + statusRegexp="\x02\\s*(\\?.)\\r", + statusParse=_toledo8217StatusParse, + commandDelay=0.2, + weightDelay=0.5, + newWeightDelay=0.2, + commandTerminator='', + weightCommand='W', + zeroCommand='Z', + tareCommand='T', + clearCommand='C', + emptyAnswerValid=False, + autoResetWeight=False, +) + +# The ADAM scales have their own RS232 protocol, usually documented in the scale's manual +# e.g at https://www.adamequipment.com/media/docs/Print%20Publications/Manuals/PDF/AZEXTRA/AZEXTRA-UM.pdf +# https://www.manualslib.com/manual/879782/Adam-Equipment-Cbd-4.html?page=32#manual +# Only the baudrate and label format seem to be configurable in the AZExtra series. +ADAMEquipmentProtocol = ScaleProtocol( + name='Adam Equipment', + baudrate=4800, + bytesize=serial.EIGHTBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_NONE, + timeout=0.2, + writeTimeout=0.2, + weightRegexp=r"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work + statusRegexp=None, + statusParse=None, + commandTerminator="\r\n", + commandDelay=0.2, + weightDelay=0.5, + newWeightDelay=5, # AZExtra beeps every time you ask for a weight that was previously returned! + # Adding an extra delay gives the operator a chance to remove the products + # before the scale starts beeping. Could not find a way to disable the beeps. + weightCommand='P', + zeroCommand='Z', + tareCommand='T', + clearCommand=None, # No clear command -> Tare again + emptyAnswerValid=True, # AZExtra does not answer unless a new non-zero weight has been detected + autoResetWeight=True, # AZExtra will not return 0 after removing products +) + + +SCALE_PROTOCOLS = ( + Toledo8217Protocol, + ADAMEquipmentProtocol, # must be listed last, as it supports no probing! +) + class Scale(Thread): def __init__(self): Thread.__init__(self) @@ -33,8 +125,8 @@ class Scale(Thread): self.weight = 0 self.weight_info = 'ok' self.device = None - self.probed_device_paths = [] self.path_to_scale = '' + self.protocol = None def lockedstart(self): with self.lock: @@ -42,15 +134,15 @@ class Scale(Thread): self.daemon = True self.start() - def set_status(self, status, message = None): + def set_status(self, status, message=None): if status == self.status['status']: - if message != None and message != self.status['messages'][-1]: + if message is not None and message != self.status['messages'][-1]: self.status['messages'].append(message) if status == 'error' and message: - _logger.error('Scale Error: '+message) + _logger.error('Scale Error: '+ message) elif status == 'disconnected' and message: - _logger.warning('Disconnected Scale: '+message) + _logger.warning('Disconnected Scale: '+ message) else: self.status['status'] = status if message: @@ -59,61 +151,105 @@ class Scale(Thread): self.status['messages'] = [] if status == 'error' and message: - _logger.error('Scale Error: '+message) + _logger.error('Scale Error: '+ message) elif status == 'disconnected' and message: _logger.warning('Disconnected Scale: '+message) def _get_raw_response(self, connection): - response = "" + answer = [] while True: - byte = connection.read(1) - - if byte: - response += byte + char = connection.read(1) # may return `bytes` or `str` + if not char: + break else: - return response + answer.append(char) + return ''.join(answer) + + def _parse_weight_answer(self, protocol, answer): + """ Parse a scale's answer to a weighing request, returning + a `(weight, weight_info, status)` pair. + """ + weight, weight_info, status = None, None, None + try: + _logger.debug("Parsing weight [%r]", answer) + if not answer and protocol.emptyAnswerValid: + # Some scales do not return the same value again, but we + # should not clear the weight data, POS may still be reading it + return weight, weight_info, status + + if protocol.statusRegexp and re.search(protocol.statusRegexp, answer): + # parse status to set weight_info - we'll try weighing again later + weight, weight_info = protocol.statusParse(answer) + else: + match = re.search(protocol.weightRegexp, answer) + if match: + weight_text = match.group(1) + try: + weight = float(weight_text) + _logger.info('Weight: %s', weight) + except ValueError: + _logger.exception("Cannot parse weight [%r]", weight_text) + status = 'Invalid weight, please power-cycle the scale' + else: + _logger.error("Cannot parse scale answer [%r]", answer) + status = 'Invalid scale answer, please power-cycle the scale' + except Exception as e: + _logger.exception("Cannot parse scale answer [%r]", answer) + status = ("Could not weigh on scale %s with protocol %s: %s" % + (self.path_to_scale, protocol.name, e)) + return weight, weight_info, status def get_device(self): - try: - if not os.path.exists(self.input_dir): - self.set_status('disconnected','Scale Not Found') - return None - devices = [ device for device in listdir(self.input_dir)] + if self.device: + return self.device + + with hw_proxy.rs232_lock: + try: + if not os.path.exists(self.input_dir): + self.set_status('disconnected', 'No RS-232 device found') + return None + + devices = [device for device in listdir(self.input_dir)] - if len(devices) > 0: for device in devices: + driver = hw_proxy.rs232_devices.get(device) + if driver and driver != DRIVER_NAME: + # belongs to another driver + _logger.info('Ignoring %s, belongs to %s', device, driver) + continue path = self.input_dir + device - - # don't keep probing devices that are not a scale, - # only keep probing if in the past the device was - # confirmed to be a scale - if path not in self.probed_device_paths or path == self.path_to_scale: - _logger.debug('Probing: ' + path) + for protocol in SCALE_PROTOCOLS: + _logger.info('Probing %s with protocol %s', path, protocol) connection = serial.Serial(path, - baudrate = 9600, - bytesize = serial.SEVENBITS, - stopbits = serial.STOPBITS_ONE, - parity = serial.PARITY_EVEN, - timeout = 1, - writeTimeout = 1) - - connection.write("W") - self.probed_device_paths.append(path) - - if self._get_raw_response(connection): - _logger.debug(path + ' is scale') + baudrate=protocol.baudrate, + bytesize=protocol.bytesize, + stopbits=protocol.stopbits, + parity=protocol.parity, + timeout=1, # longer timeouts for probing + writeTimeout=1) # longer timeouts for probing + connection.write(protocol.weightCommand + protocol.commandTerminator) + time.sleep(protocol.commandDelay) + answer = self._get_raw_response(connection) + weight, weight_info, status = self._parse_weight_answer(protocol, answer) + if status: + _logger.info('Probing %s: no valid answer to protocol %s', path, protocol.name) + else: + _logger.info('Probing %s: answer looks ok for protocol %s', path, protocol.name) self.path_to_scale = path - self.set_status('connected','Connected to '+device) - connection.timeout = 0.02 - connection.writeTimeout = 0.02 + self.protocol = protocol + self.set_status( + 'connected', + 'Connected to %s with %s protocol' % (device, protocol.name) + ) + connection.timeout = protocol.timeout + connection.writeTimeout = protocol.writeTimeout + hw_proxy.rs232_devices[path] = DRIVER_NAME return connection - else: - _logger.debug('Already probed: ' + path) - self.set_status('disconnected','Scale Not Found') - return None - except Exception as e: - self.set_status('error',str(e)) + self.set_status('disconnected', 'No supported RS-232 scale found') + except Exception as e: + _logger.exception('Failed probing for scales') + self.set_status('error', 'Failed probing for scales: %s' % e) return None def get_weight(self): @@ -123,109 +259,110 @@ class Scale(Thread): def get_weight_info(self): self.lockedstart() return self.weight_info - + def get_status(self): self.lockedstart() return self.status def read_weight(self): with self.scalelock: - if self.device: - try: - self.device.write('W') - time.sleep(0.2) - answer = [] - - while True: - char = self.device.read(1) - if not char: - break - else: - answer.append(char) - - if '?' in answer: - stat = ord(answer[answer.index('?')+1]) - if stat == 0: - self.weight_info = 'ok' - else: - self.weight_info = [] - if stat & 1 : - self.weight_info.append('moving') - if stat & 1 << 1: - self.weight_info.append('over_capacity') - if stat & 1 << 2: - self.weight_info.append('negative') - self.weight = 0.0 - if stat & 1 << 3: - self.weight_info.append('outside_zero_capture_range') - if stat & 1 << 4: - self.weight_info.append('center_of_zero') - if stat & 1 << 5: - self.weight_info.append('net_weight') - else: - answer = answer[1:-1] - if 'N' in answer: - answer = answer[0:-1] - try: - self.weight = float(''.join(answer)) - except ValueError as v: - self.set_status('error','No data Received, please power-cycle the scale'); - self.device = None - - except Exception as e: - self.set_status('error',str(e)) + p = self.protocol + try: + self.device.write(p.weightCommand + p.commandTerminator) + time.sleep(p.commandDelay) + answer = self._get_raw_response(self.device) + weight, weight_info, status = self._parse_weight_answer(p, answer) + if status: + self.set_status('error', status) self.device = None + else: + if weight is not None: + self.weight = weight + if weight_info is not None: + self.weight_info = weight_info + except Exception as e: + self.set_status( + 'error', + "Could not weigh on scale %s with protocol %s: %s" % + (self.path_to_scale, p.name, e)) + self.device = None def set_zero(self): with self.scalelock: if self.device: - try: - self.device.write('Z') + try: + self.device.write(self.protocol.zeroCommand + self.protocol.commandTerminator) + time.sleep(self.protocol.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not zero scale %s with protocol %s: %s" % + (self.path_to_scale, self.protocol.name, e)) self.device = None def set_tare(self): with self.scalelock: if self.device: - try: - self.device.write('T') + try: + self.device.write(self.protocol.tareCommand + self.protocol.commandTerminator) + time.sleep(self.protocol.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not tare scale %s with protocol %s: %s" % + (self.path_to_scale, self.protocol.name, e)) self.device = None def clear_tare(self): with self.scalelock: if self.device: - try: - self.device.write('C') + p = self.protocol + try: + # if the protocol has no clear, we can just tare again + clearCommand = p.clearCommand or p.tareCommand + self.device.write(clearCommand + p.commandTerminator) + time.sleep(p.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not clear tare on scale %s with protocol %s: %s" % + (self.path_to_scale, p.name, e)) self.device = None def run(self): - self.device = None + self.device = None - while True: + while True: if self.device: + old_weight = self.weight self.read_weight() - time.sleep(0.15) + if self.weight != old_weight: + _logger.info('New Weight: %s, sleeping %ss', self.weight, self.protocol.newWeightDelay) + time.sleep(self.protocol.newWeightDelay) + if self.weight and self.protocol.autoResetWeight: + self.weight = 0 + else: + _logger.info('Weight: %s, sleeping %ss', self.weight, self.protocol.weightDelay) + time.sleep(self.protocol.weightDelay) else: with self.scalelock: self.device = self.get_device() if not self.device: - time.sleep(5) + # retry later to support "plug and play" + time.sleep(10) scale_thread = None if serial: scale_thread = Scale() - hw_proxy.drivers['scale'] = scale_thread + hw_proxy.drivers[DRIVER_NAME] = scale_thread class ScaleDriver(hw_proxy.Proxy): @http.route('/hw_proxy/scale_read/', type='json', auth='none', cors='*') def scale_read(self): if scale_thread: - return {'weight': scale_thread.get_weight(), 'unit':'kg', 'info': scale_thread.get_weight_info()} + return {'weight': scale_thread.get_weight(), + 'unit': 'kg', + 'info': scale_thread.get_weight_info()} return None @http.route('/hw_proxy/scale_zero/', type='json', auth='none', cors='*') @@ -245,5 +382,3 @@ class ScaleDriver(hw_proxy.Proxy): if scale_thread: scale_thread.clear_tare() return True - -