File: main.py

package info (click to toggle)
oca-core 11.0.20180730-1
  • links: PTS, VCS
  • area: main
  • in suites: buster
  • size: 509,684 kB
  • sloc: xml: 258,806; python: 164,081; sql: 217; sh: 92; makefile: 16
file content (387 lines) | stat: -rw-r--r-- 15,544 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# -*- 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 Thread, Lock

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 "
    "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,
)

# 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=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
)


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.weight = 0
        self.weight_info = 'ok'
        self.device = None
        self.path_to_scale = ''
        self.protocol = None

    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

                devices = [device for device in listdir(self.input_dir)]

                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
                    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.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 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)
            else:
                with self.scalelock:
                    self.device = self.get_device()
                if not self.device:
                    # retry later to support "plug and play"
                    time.sleep(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