[IMP] iot: add iot serial drivers
This commit adds a base serial and a base serial scale iot drivers, as well as drivers extending them for the two previously supported scales (ADAM EQUIPMENT AZEXTRA and TOLEDO 8217). Since just adding the new drivers creates a race condition between the old drivers and the new, the old drivers need to be removed. The IoT scale drivers need to be backward compatible and compatible with the community version, so client using an IoT box with those versions can still connect a scale to the PoS. Task: 1892412
This commit is contained in:
@@ -18,6 +18,7 @@ from cups import Connection as cups_connection
|
||||
from glob import glob
|
||||
from base64 import b64decode
|
||||
from pathlib import Path
|
||||
from serial.tools.list_ports import comports
|
||||
import socket
|
||||
|
||||
from odoo import http, _
|
||||
@@ -124,7 +125,18 @@ iot_devices = {}
|
||||
class DriverMetaClass(type):
|
||||
def __new__(cls, clsname, bases, attrs):
|
||||
newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
|
||||
drivers.append(newclass)
|
||||
# We only add concrete classes to `drivers`.
|
||||
try:
|
||||
newclass.supported('test')
|
||||
except NotImplementedError:
|
||||
return newclass
|
||||
except Exception:
|
||||
pass
|
||||
# Some drivers must be tried only when all the others have been ruled out. These are kept at the bottom of the list.
|
||||
if newclass.is_tested_last:
|
||||
drivers.append(newclass)
|
||||
else:
|
||||
drivers.insert(0, newclass)
|
||||
return newclass
|
||||
|
||||
class Driver(Thread, metaclass=DriverMetaClass):
|
||||
@@ -132,6 +144,7 @@ class Driver(Thread, metaclass=DriverMetaClass):
|
||||
Hook to register the driver into the drivers list
|
||||
"""
|
||||
connection_type = ""
|
||||
is_tested_last = False
|
||||
|
||||
def __init__(self, device):
|
||||
super(Driver, self).__init__()
|
||||
@@ -171,7 +184,7 @@ class Driver(Thread, metaclass=DriverMetaClass):
|
||||
On specific driver override this method to check if device is supported or not
|
||||
return True or False
|
||||
"""
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
def get_message(self):
|
||||
return ''
|
||||
@@ -301,6 +314,14 @@ class Manager(Thread):
|
||||
else:
|
||||
_logger.warning('Odoo server not set')
|
||||
|
||||
def serial_loop(self):
|
||||
serial_devices = {}
|
||||
for dev in comports():
|
||||
dev.identifier = dev.location
|
||||
iot_device = IoTDevice(dev, 'serial')
|
||||
serial_devices[dev.identifier] = iot_device
|
||||
return serial_devices
|
||||
|
||||
def usb_loop(self):
|
||||
"""
|
||||
Loops over the connected usb devices, assign them an identifier, instantiate
|
||||
@@ -368,6 +389,7 @@ class Manager(Thread):
|
||||
updated_devices.update(self.video_loop())
|
||||
updated_devices.update(bt_devices)
|
||||
updated_devices.update(socket_devices)
|
||||
updated_devices.update(self.serial_loop())
|
||||
if cpt % 40 == 0:
|
||||
printer_devices = self.printer_loop()
|
||||
cpt = 0
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from collections import namedtuple
|
||||
from threading import Lock
|
||||
from contextlib import contextmanager
|
||||
|
||||
import serial
|
||||
|
||||
from odoo.tools.translate import _
|
||||
from odoo.addons.hw_drivers.controllers.driver import Driver, event_manager
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SerialProtocol = namedtuple(
|
||||
'SerialProtocol',
|
||||
"name baudrate bytesize stopbits parity timeout writeTimeout measureRegexp statusRegexp "
|
||||
"commandTerminator commandDelay measureDelay newMeasureDelay "
|
||||
"measureCommand emptyAnswerValid")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def serial_connection(path, protocol, is_probing=False):
|
||||
"""Opens a serial connection to a device and closes it automatically after use.
|
||||
|
||||
:param path: path to the device
|
||||
:type path: string
|
||||
:param protocol: an object containing the serial protocol to connect to a device
|
||||
:type protocol: namedtuple
|
||||
:param is_probing: a flag thet if set to `True` makes the timeouts longer, defaults to False
|
||||
:type is_probing: bool, optional
|
||||
"""
|
||||
|
||||
PROBING_TIMEOUT = 1
|
||||
port_config = {
|
||||
'baudrate': protocol.baudrate,
|
||||
'bytesize': protocol.bytesize,
|
||||
'stopbits': protocol.stopbits,
|
||||
'parity': protocol.parity,
|
||||
'timeout': PROBING_TIMEOUT if is_probing else protocol.timeout, # longer timeouts for probing
|
||||
'writeTimeout': PROBING_TIMEOUT if is_probing else protocol.writeTimeout # longer timeouts for probing
|
||||
}
|
||||
connection = serial.Serial(path, **port_config)
|
||||
yield connection
|
||||
connection.close()
|
||||
|
||||
|
||||
class SerialDriver(Driver):
|
||||
"""Abstract base class for serial drivers."""
|
||||
|
||||
_protocol = None
|
||||
connection_type = 'serial'
|
||||
|
||||
STATUS_CONNECTED = 'connected'
|
||||
STATUS_ERROR = 'error'
|
||||
STATUS_CONNECTING = 'connecting'
|
||||
|
||||
def __init__(self, device):
|
||||
""" Attributes initialization method for `SerialDriver`.
|
||||
|
||||
:param device: path to the device
|
||||
:type device: str
|
||||
"""
|
||||
|
||||
super().__init__(device)
|
||||
self._actions = {
|
||||
'get_status': self._push_status,
|
||||
}
|
||||
self._device_connection = 'serial'
|
||||
self._device_lock = Lock()
|
||||
self._status = {'status': self.STATUS_CONNECTING, 'message_title': '', 'message_body': ''}
|
||||
self._set_name()
|
||||
|
||||
def _get_raw_response(connection):
|
||||
pass
|
||||
|
||||
def _push_status(self):
|
||||
"""Updates the current status and pushes it to the frontend."""
|
||||
|
||||
self.data['status'] = self._status
|
||||
event_manager.device_changed(self)
|
||||
|
||||
def _set_name(self):
|
||||
"""Tries to build the device's name based on its type and protocol name but falls back on a default name if that doesn't work."""
|
||||
|
||||
try:
|
||||
name = ('%s serial %s' % (self._protocol.name, self._device_type)).title()
|
||||
except Exception:
|
||||
name = 'Unknown Serial Device'
|
||||
self._device_name = name
|
||||
|
||||
def _take_measure(self):
|
||||
pass
|
||||
|
||||
def _do_action(self, data):
|
||||
"""Helper function that calls a specific action method on the device.
|
||||
|
||||
:param data: the `_actions` key mapped to the action method we want to call
|
||||
:type data: string
|
||||
"""
|
||||
|
||||
try:
|
||||
with self._device_lock:
|
||||
self._actions[data]()
|
||||
time.sleep(self._protocol.commandDelay)
|
||||
except Exception:
|
||||
msg = _('An error occured while performing action %s on %s') % (data, self.device_name)
|
||||
_logger.exception(msg)
|
||||
self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()}
|
||||
self._push_status()
|
||||
|
||||
def action(self, data):
|
||||
"""Establish a connection with the device if needed and have it perform a specific action.
|
||||
|
||||
:param data: the `_actions` key mapped to the action method we want to call
|
||||
:type data: string
|
||||
"""
|
||||
|
||||
if self._connection and self._connection.isOpen():
|
||||
self._do_action(data)
|
||||
else:
|
||||
with serial_connection(self.dev.device, self._protocol) as connection:
|
||||
self._connection = connection
|
||||
self._do_action(data)
|
||||
|
||||
def run(self):
|
||||
"""Continuously gets new measures from the device."""
|
||||
|
||||
try:
|
||||
with serial_connection(self.dev.device, self._protocol) as connection:
|
||||
self._connection = connection
|
||||
self._status['status'] = self.STATUS_CONNECTED
|
||||
self._push_status()
|
||||
while True:
|
||||
self._take_measure()
|
||||
time.sleep(self._protocol.newMeasureDelay)
|
||||
except Exception:
|
||||
msg = _('Error while reading %s') % self.device_name
|
||||
_logger.exception(msg)
|
||||
self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()}
|
||||
self._push_status()
|
||||
@@ -0,0 +1,308 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
import threading
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import namedtuple
|
||||
|
||||
import serial
|
||||
|
||||
from odoo import http
|
||||
from odoo.addons.hw_proxy.controllers.main import drivers as old_drivers
|
||||
from odoo.addons.hw_drivers.controllers.driver import event_manager
|
||||
from odoo.addons.hw_drivers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Only needed to ensure compatibility with older versions of Odoo
|
||||
ACTIVE_SCALE = None
|
||||
new_weight_event = threading.Event()
|
||||
|
||||
ScaleProtocol = namedtuple('ScaleProtocol', SerialProtocol._fields + ('zeroCommand', 'tareCommand', 'clearCommand', '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,
|
||||
measureRegexp=b"\x02\\s*([0-9.]+)N?\\r",
|
||||
statusRegexp=b"\x02\\s*(\\?.)\\r",
|
||||
commandDelay=0.2,
|
||||
measureDelay=0.5,
|
||||
newMeasureDelay=0.2,
|
||||
commandTerminator=b'',
|
||||
measureCommand=b'W',
|
||||
zeroCommand=b'Z',
|
||||
tareCommand=b'T',
|
||||
clearCommand=b'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,
|
||||
measureRegexp=b"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work
|
||||
statusRegexp=None,
|
||||
commandTerminator=b"\r\n",
|
||||
commandDelay=0.2,
|
||||
measureDelay=0.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.
|
||||
newMeasureDelay=5,
|
||||
measureCommand=b'P',
|
||||
zeroCommand=b'Z',
|
||||
tareCommand=b'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
|
||||
)
|
||||
|
||||
|
||||
# Ensures compatibility with older versions of Odoo
|
||||
class ScaleReadOldRoute(http.Controller):
|
||||
@http.route('/hw_proxy/scale_read', type='json', auth='none', cors='*')
|
||||
def scale_read(self):
|
||||
if ACTIVE_SCALE:
|
||||
return {'weight': ACTIVE_SCALE._scale_read_old_route()}
|
||||
return None
|
||||
|
||||
|
||||
class ScaleDriver(SerialDriver):
|
||||
"""Abstract base class for scale drivers."""
|
||||
|
||||
def __init__(self, device):
|
||||
self._device_type = 'scale'
|
||||
super().__init__(device)
|
||||
self._set_actions()
|
||||
self._is_reading = True
|
||||
|
||||
# Ensures compatibility with older versions of Odoo
|
||||
# Only the last scale connected is kept
|
||||
global ACTIVE_SCALE
|
||||
ACTIVE_SCALE = self
|
||||
old_drivers['scale'] = ACTIVE_SCALE
|
||||
|
||||
# Ensures compatibility with older versions of Odoo
|
||||
# and allows using the `ProxyDevice` in the point of sale to retrieve the status
|
||||
def get_status(self):
|
||||
"""Allows `hw_proxy.Proxy` to retrieve the status of the scales"""
|
||||
|
||||
status = self._status
|
||||
return {'status': status['status'], 'messages': [status['message_title'], ]}
|
||||
|
||||
def _set_actions(self):
|
||||
"""Initializes `self._actions`, a map of action keys sent by the frontend to backend action methods."""
|
||||
|
||||
self._actions.update({
|
||||
'read_once': self._read_once_action,
|
||||
'set_zero': self._set_zero_action,
|
||||
'set_tare': self._set_tare_action,
|
||||
'clear_tare': self._clear_tare_action,
|
||||
'start_reading': self._start_reading_action,
|
||||
'stop_reading': self._stop_reading_action,
|
||||
})
|
||||
|
||||
def _start_reading_action(self):
|
||||
"""Starts asking for the scale value."""
|
||||
self._is_reading = True
|
||||
|
||||
def _stop_reading_action(self):
|
||||
"""Stops asking for the scale value."""
|
||||
self._is_reading = False
|
||||
|
||||
def _clear_tare_action(self):
|
||||
"""Clears the scale current tare weight."""
|
||||
|
||||
# if the protocol has no clear tare command, we can just tare again
|
||||
clearCommand = self._protocol.clearCommand or self._protocol.tareCommand
|
||||
self._connection.write(clearCommand + self._protocol.commandTerminator)
|
||||
|
||||
def _read_once_action(self):
|
||||
"""Reads the scale current weight value and pushes it to the frontend."""
|
||||
|
||||
self._read_weight()
|
||||
event_manager.device_changed(self)
|
||||
|
||||
def _set_zero_action(self):
|
||||
"""Makes the weight currently applied to the scale the new zero."""
|
||||
|
||||
self._connection.write(self._protocol.zeroCommand + self._protocol.commandTerminator)
|
||||
|
||||
def _set_tare_action(self):
|
||||
"""Sets the scale's current weight value as tare weight."""
|
||||
|
||||
self._connection.write(self._protocol.tareCommand + self._protocol.commandTerminator)
|
||||
|
||||
@staticmethod
|
||||
def _get_raw_response(connection):
|
||||
"""Gets raw bytes containing the updated value of the device.
|
||||
|
||||
:param connection: a connection to the device's serial port
|
||||
:type connection: pyserial.Serial
|
||||
:return: the raw response to a weight request
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
answer = []
|
||||
while True:
|
||||
char = connection.read(1)
|
||||
if not char:
|
||||
break
|
||||
else:
|
||||
answer.append(bytes(char))
|
||||
return b''.join(answer)
|
||||
|
||||
def _read_weight(self):
|
||||
"""Asks for a new weight from the scale, checks if it is valid and, if it is, makes it the current value."""
|
||||
|
||||
protocol = self._protocol
|
||||
self._connection.write(protocol.measureCommand + protocol.commandTerminator)
|
||||
answer = self._get_raw_response(self._connection)
|
||||
match = re.search(self._protocol.measureRegexp, answer)
|
||||
self.data = {
|
||||
'value': float(match.group(1)) if match else None,
|
||||
'status': self._status
|
||||
}
|
||||
|
||||
# Ensures compatibility with older versions of Odoo
|
||||
def _scale_read_old_route(self):
|
||||
"""Used when the iot app is not installed"""
|
||||
with self._device_lock:
|
||||
self._read_weight()
|
||||
return self.data['value']
|
||||
|
||||
def _take_measure(self):
|
||||
"""Reads the device's weight value, and pushes that value to the frontend."""
|
||||
|
||||
with self._device_lock:
|
||||
self._read_weight()
|
||||
if self.data['value'] is not None or self._status['status'] == self.STATUS_ERROR:
|
||||
event_manager.device_changed(self)
|
||||
|
||||
|
||||
class Toledo8217Driver(ScaleDriver):
|
||||
"""Driver for the Toldedo 8217 serial scale."""
|
||||
_protocol = Toledo8217Protocol
|
||||
|
||||
@classmethod
|
||||
def supported(cls, device):
|
||||
"""Checks whether the device, which port info is passed as argument, is supported by the driver.
|
||||
|
||||
:param device: path to the device
|
||||
:type device: str
|
||||
:return: whether the device is supported by the driver
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
protocol = cls._protocol
|
||||
|
||||
try:
|
||||
with serial_connection(device.device, protocol, is_probing=True) as connection:
|
||||
connection.write(b'Ehello' + protocol.commandTerminator)
|
||||
time.sleep(protocol.commandDelay)
|
||||
answer = connection.read(8)
|
||||
if answer == b'\x02E\rhello':
|
||||
connection.write(b'F' + protocol.commandTerminator)
|
||||
return True
|
||||
except serial.serialutil.SerialTimeoutException:
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception('Error while probing %s with protocol %s' % (device, protocol.name))
|
||||
return False
|
||||
|
||||
|
||||
class AdamEquipmentDriver(ScaleDriver):
|
||||
"""Driver for the Adam Equipment serial scale."""
|
||||
|
||||
_protocol = ADAMEquipmentProtocol
|
||||
is_tested_last = True
|
||||
|
||||
def __init__(self, device):
|
||||
super().__init__(device)
|
||||
self._is_reading = False
|
||||
self._last_weight_time = 0
|
||||
|
||||
def _check_last_weight_time(self):
|
||||
"""The ADAM doesn't make the difference between a value of 0 and "the same value as last time":
|
||||
in both cases it returns an empty string.
|
||||
With this, unless the weight changes, we give the user `TIME_WEIGHT_KEPT` seconds to log the new weight,
|
||||
then change it back to zero to avoid keeping it indefinetely, which could cause issues.
|
||||
In any case the ADAM must always go back to zero before it can weight again.
|
||||
"""
|
||||
|
||||
TIME_WEIGHT_KEPT = 10
|
||||
|
||||
if self.data['value'] is None:
|
||||
if time.time() - self._last_weight_time > TIME_WEIGHT_KEPT:
|
||||
self.data['value'] = 0
|
||||
else:
|
||||
self._last_weight_time = time.time()
|
||||
|
||||
def _take_measure(self):
|
||||
"""Reads the device's weight value, and pushes that value to the frontend."""
|
||||
|
||||
if self._is_reading:
|
||||
with self._device_lock:
|
||||
self._read_weight()
|
||||
self._check_last_weight_time()
|
||||
if self.data['value'] is not None or self._status['status'] == self.STATUS_ERROR:
|
||||
event_manager.device_changed(self)
|
||||
else:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Ensures compatibility with older versions of Odoo
|
||||
def _scale_read_old_route(self):
|
||||
"""Used when the iot app is not installed"""
|
||||
|
||||
time.sleep(3)
|
||||
with self._device_lock:
|
||||
self._read_weight()
|
||||
self._check_last_weight_time()
|
||||
return self.data['value']
|
||||
|
||||
@classmethod
|
||||
def supported(cls, device):
|
||||
"""Checks whether the device at `device` is supported by the driver.
|
||||
|
||||
:param device: path to the device
|
||||
:type device: str
|
||||
:return: whether the device is supported by the driver
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
protocol = cls._protocol
|
||||
|
||||
try:
|
||||
with serial_connection(device.device, protocol, is_probing=True) as connection:
|
||||
connection.write(protocol.measureCommand + protocol.commandTerminator)
|
||||
# Checking whether writing to the serial port using the Adam protocol raises a timeout exception is about the only thing we can do.
|
||||
return True
|
||||
except serial.serialutil.SerialTimeoutException:
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception('Error while probing %s with protocol %s' % (device, protocol.name))
|
||||
return False
|
||||
@@ -90,13 +90,15 @@ class IoTboxHomepage(web.Home):
|
||||
|
||||
pos_device = self.get_pos_device_status()
|
||||
iot_device = []
|
||||
for status in pos_device:
|
||||
if pos_device[status]['status'] == 'connected':
|
||||
iot_device.append({
|
||||
'name': status,
|
||||
'type': 'device',
|
||||
'message': ' '.join(pos_device[status]['messages'])
|
||||
})
|
||||
|
||||
if not iot_devices:
|
||||
for status in pos_device:
|
||||
if pos_device[status]['status'] == 'connected':
|
||||
iot_device.append({
|
||||
'name': status,
|
||||
'type': 'device',
|
||||
'message': ' '.join(pos_device[status]['messages'])
|
||||
})
|
||||
|
||||
for device in iot_devices:
|
||||
iot_device.append({
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
@@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Weighing Scale Hardware Driver',
|
||||
'category': 'Point of Sale',
|
||||
'sequence': 6,
|
||||
'summary': 'Hardware Driver for Weighing Scales',
|
||||
'website': 'https://www.odoo.com/page/point-of-sale-hardware',
|
||||
'description': """
|
||||
Weighing Scale Hardware Driver
|
||||
================================
|
||||
|
||||
This module allows the point of sale to connect to a scale using a USB HSM Serial Scale Interface,
|
||||
such as the Mettler Toledo Ariva.
|
||||
|
||||
""",
|
||||
'depends': ['hw_proxy'],
|
||||
'external_dependencies': {'python': ['serial']},
|
||||
'installable': False,
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
@@ -1,404 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from collections import namedtuple
|
||||
from os import listdir
|
||||
from threading import Event, Lock, Thread
|
||||
|
||||
from odoo import http
|
||||
|
||||
from odoo.addons.hw_proxy.controllers import main as hw_proxy
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
DRIVER_NAME = 'scale'
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
_logger.error('Odoo module hw_scale depends on the pyserial python module')
|
||||
serial = None
|
||||
|
||||
|
||||
def _toledo8217StatusParse(status):
|
||||
""" Parse a scale's status, returning a `(weight, weight_info)` pair. """
|
||||
weight, weight_info = None, None
|
||||
stat = status[status.index(b'?') + 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 disable "
|
||||
"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=b"\x02\\s*([0-9.]+)N?\\r",
|
||||
statusRegexp=b"\x02\\s*(\\?.)\\r",
|
||||
statusParse=_toledo8217StatusParse,
|
||||
commandDelay=0.2,
|
||||
weightDelay=0.5,
|
||||
newWeightDelay=0.2,
|
||||
commandTerminator=b'',
|
||||
weightCommand=b'W',
|
||||
zeroCommand=b'Z',
|
||||
tareCommand=b'T',
|
||||
clearCommand=b'C',
|
||||
emptyAnswerValid=False,
|
||||
autoResetWeight=False,
|
||||
disable=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=b"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work
|
||||
statusRegexp=None,
|
||||
statusParse=None,
|
||||
commandTerminator=b"\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=b'P',
|
||||
zeroCommand=b'Z',
|
||||
tareCommand=b'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
|
||||
disable=True
|
||||
)
|
||||
|
||||
|
||||
SCALE_PROTOCOLS = (
|
||||
Toledo8217Protocol,
|
||||
ADAMEquipmentProtocol, # must be listed last, as it supports no probing!
|
||||
)
|
||||
|
||||
class Scale(Thread):
|
||||
def __init__(self):
|
||||
Thread.__init__(self)
|
||||
self.lock = Lock()
|
||||
self.scalelock = Lock()
|
||||
self.status = {'status':'connecting', 'messages':[]}
|
||||
self.input_dir = '/dev/serial/by-path/'
|
||||
self.forbidden_dir = '/dev/serial/by-id/'
|
||||
self.weight = 0
|
||||
self.weight_info = 'ok'
|
||||
self.device = None
|
||||
self.path_to_scale = ''
|
||||
self.protocol = None
|
||||
self.disabled = False
|
||||
self.weight_request_event = Event()
|
||||
|
||||
def lockedstart(self):
|
||||
with self.lock:
|
||||
if not self.isAlive():
|
||||
self.daemon = True
|
||||
self.start()
|
||||
|
||||
def set_status(self, status, message=None):
|
||||
if status == self.status['status']:
|
||||
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)
|
||||
elif status == 'disconnected' and message:
|
||||
_logger.warning('Disconnected Scale: '+ message)
|
||||
else:
|
||||
self.status['status'] = status
|
||||
if message:
|
||||
self.status['messages'] = [message]
|
||||
else:
|
||||
self.status['messages'] = []
|
||||
|
||||
if status == 'error' and message:
|
||||
_logger.error('Scale Error: '+ message)
|
||||
elif status == 'disconnected' and message:
|
||||
_logger.info('Disconnected Scale: %s', message)
|
||||
|
||||
def _get_raw_response(self, connection):
|
||||
answer = []
|
||||
while True:
|
||||
char = connection.read(1) # may return `bytes` or `str`
|
||||
if not char:
|
||||
break
|
||||
else:
|
||||
answer.append(bytes(char))
|
||||
return b''.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):
|
||||
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
|
||||
|
||||
forbidden_devices = [os.readlink(self.forbidden_dir + d) for d in listdir(self.forbidden_dir) if 'usb-Sylvac_Power_USB_A32DV5VM' in d] # Skip special usb link with Sylvac
|
||||
devices = [device for device in listdir(self.input_dir) if os.readlink(self.input_dir + device) not in forbidden_devices]
|
||||
for device in devices:
|
||||
path = self.input_dir + device
|
||||
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
|
||||
|
||||
for protocol in SCALE_PROTOCOLS:
|
||||
_logger.info('Probing %s with protocol %s', path, protocol)
|
||||
connection = serial.Serial(path,
|
||||
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.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
|
||||
|
||||
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):
|
||||
self.disabled = False
|
||||
self.weight_request_event.set()
|
||||
self.lockedstart()
|
||||
return self.weight
|
||||
|
||||
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:
|
||||
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(self.protocol.zeroCommand + self.protocol.commandTerminator)
|
||||
time.sleep(self.protocol.commandDelay)
|
||||
except Exception as 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(self.protocol.tareCommand + self.protocol.commandTerminator)
|
||||
time.sleep(self.protocol.commandDelay)
|
||||
except Exception as 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:
|
||||
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',
|
||||
"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
|
||||
|
||||
while True:
|
||||
if not self.disabled:
|
||||
if self.device:
|
||||
old_weight = self.weight
|
||||
self.read_weight()
|
||||
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)
|
||||
self.disabled = True
|
||||
else:
|
||||
with self.scalelock:
|
||||
self.device = self.get_device()
|
||||
if not self.device:
|
||||
# retry later to support "plug and play"
|
||||
self.weight_request_event.clear()
|
||||
self.weight_request_event.wait(10)
|
||||
else:
|
||||
self.disabled = self.protocol.disable
|
||||
else:
|
||||
self.weight_request_event.clear()
|
||||
self.weight_request_event.wait(10)
|
||||
|
||||
|
||||
scale_thread = None
|
||||
if serial:
|
||||
scale_thread = Scale()
|
||||
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 None
|
||||
|
||||
@http.route('/hw_proxy/scale_zero/', type='json', auth='none', cors='*')
|
||||
def scale_zero(self):
|
||||
if scale_thread:
|
||||
scale_thread.set_zero()
|
||||
return True
|
||||
|
||||
@http.route('/hw_proxy/scale_tare/', type='json', auth='none', cors='*')
|
||||
def scale_tare(self):
|
||||
if scale_thread:
|
||||
scale_thread.set_tare()
|
||||
return True
|
||||
|
||||
@http.route('/hw_proxy/scale_clear_tare/', type='json', auth='none', cors='*')
|
||||
def scale_clear_tare(self):
|
||||
if scale_thread:
|
||||
scale_thread.clear_tare()
|
||||
return True
|
||||
@@ -1,16 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 9.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
|
||||
"PO-Revision-Date: 2015-09-07 14:39+0000\n"
|
||||
"Last-Translator: <>\n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
@@ -26,7 +26,7 @@ test -x $DAEMON || exit 0
|
||||
set -e
|
||||
|
||||
function _start() {
|
||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --chuid $USER:$USER --background --make-pidfile --exec $DAEMON -- --config $CONFIG --logfile $LOGFILE --load=web,hw_proxy,hw_posbox_homepage,hw_posbox_upgrade,hw_scale,hw_scanner,hw_escpos,hw_blackbox_be,hw_screen,hw_drivers
|
||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --chuid $USER:$USER --background --make-pidfile --exec $DAEMON -- --config $CONFIG --logfile $LOGFILE --load=web,hw_proxy,hw_posbox_homepage,hw_posbox_upgrade,hw_scanner,hw_escpos,hw_blackbox_be,hw_screen,hw_drivers
|
||||
}
|
||||
|
||||
function _stop() {
|
||||
|
||||
Reference in New Issue
Block a user