[MERGE] forward port branch 9.0 up to 7ee09ed
This commit is contained in:
@@ -152,6 +152,7 @@ class AccountMove(models.Model):
|
||||
if not move.journal_id.update_posted:
|
||||
raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.'))
|
||||
if self.ids:
|
||||
self._check_lock_date()
|
||||
self._cr.execute('UPDATE account_move '\
|
||||
'SET state=%s '\
|
||||
'WHERE id IN %s', ('draft', tuple(self.ids),))
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
<field name="code"/>
|
||||
<field name="refund_sequence" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}" groups="base.group_no_one"/>
|
||||
<field name="sequence_id" required="0" attrs="{'readonly': 1}" groups="base.group_no_one"/>
|
||||
<field name="refund_sequence_id" attrs="{'invisible': ['|',('type', 'not in', ['sale', 'purchase']), ('refund_sequence', '!=', True)], 'required': [('type', 'in', ['sale', 'purchase']), ('refund_sequence', '=', True)]}" groups="base.group_no_one"/>
|
||||
<field name="refund_sequence_id" attrs="{'invisible': ['|',('type', 'not in', ['sale', 'purchase']), ('refund_sequence', '!=', True)], 'required': [('type', 'in', ['sale', 'purchase']), ('refund_sequence', '=', True)]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="default_debit_account_id" domain="[('deprecated', '=', False)]" />
|
||||
|
||||
@@ -14,6 +14,8 @@ import openerp.addons.hw_proxy.controllers.main as hw_proxy
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
DRIVER_NAME = 'fiscal_data_module'
|
||||
|
||||
class Blackbox(Thread):
|
||||
def __init__(self):
|
||||
Thread.__init__(self)
|
||||
@@ -39,26 +41,31 @@ class Blackbox(Thread):
|
||||
# request. The first device to give an answer that makes sense
|
||||
# wins.
|
||||
def _find_device_path_by_probing(self):
|
||||
path = "/dev/serial/by-path/"
|
||||
probe_message = self._wrap_low_level_message_around("S000")
|
||||
with hw_proxy.rs232_lock:
|
||||
path = "/dev/serial/by-path/"
|
||||
probe_message = self._wrap_low_level_message_around("S000")
|
||||
|
||||
try:
|
||||
devices = listdir(path)
|
||||
except OSError:
|
||||
_logger.warning(path + " doesn't exist")
|
||||
else:
|
||||
for device in listdir(path):
|
||||
path_to_device = path + device
|
||||
_logger.debug("Probing " + device)
|
||||
try:
|
||||
devices = listdir(path)
|
||||
except OSError:
|
||||
_logger.warning(path + " doesn't exist")
|
||||
self.set_status("disconnected", ["No RS-232 device (or emulated ones) found"])
|
||||
else:
|
||||
for device in devices:
|
||||
if device in hw_proxy.rs232_devices:
|
||||
continue
|
||||
path_to_device = path + device
|
||||
_logger.debug("Probing " + device)
|
||||
|
||||
if self._send_to_blackbox(probe_message, 21, path_to_device, just_wait_for_ack=True):
|
||||
_logger.info(device + " will be used as the blackbox")
|
||||
self.set_status("connected", [device])
|
||||
return path_to_device
|
||||
if self._send_to_blackbox(probe_message, 21, path_to_device, just_wait_for_ack=True):
|
||||
_logger.info(device + " will be used as the blackbox")
|
||||
self.set_status("connected", [device])
|
||||
hw_proxy.rs232_devices[device] = DRIVER_NAME
|
||||
return path_to_device
|
||||
|
||||
_logger.warning("Blackbox could not be found")
|
||||
self.set_status("error", ["Couldn't find the Fiscal Data Module"])
|
||||
return ""
|
||||
_logger.warning("Blackbox could not be found")
|
||||
self.set_status("disconnected", ["Couldn't find the Fiscal Data Module"])
|
||||
return ""
|
||||
|
||||
def _lrc(self, msg):
|
||||
lrc = 0
|
||||
@@ -152,7 +159,7 @@ class Blackbox(Thread):
|
||||
|
||||
if isfile("/home/pi/registered_blackbox_be"):
|
||||
blackbox_thread = Blackbox()
|
||||
hw_proxy.drivers['fiscal_data_module'] = blackbox_thread
|
||||
hw_proxy.drivers[DRIVER_NAME] = blackbox_thread
|
||||
|
||||
class BlackboxDriver(hw_proxy.Proxy):
|
||||
@http.route('/hw_proxy/request_blackbox/', type='json', auth='none', cors='*')
|
||||
|
||||
@@ -104,8 +104,12 @@ class EscposDriver(Thread):
|
||||
|
||||
printers = self.connected_usb_devices()
|
||||
if len(printers) > 0:
|
||||
self.set_status('connected','Connected to '+printers[0]['name'])
|
||||
return Usb(printers[0]['vendor'], printers[0]['product'])
|
||||
print_dev = Usb(printers[0]['vendor'], printers[0]['product'])
|
||||
self.set_status(
|
||||
'connected',
|
||||
"Connected to %s (in=0x%02x,out=0x%02x)" % (printers[0]['name'], print_dev.in_ep, print_dev.out_ep)
|
||||
)
|
||||
return print_dev
|
||||
else:
|
||||
self.set_status('disconnected','Printer Not Found')
|
||||
return None
|
||||
|
||||
@@ -13,7 +13,7 @@ from time import sleep
|
||||
class Usb(Escpos):
|
||||
""" Define USB printer """
|
||||
|
||||
def __init__(self, idVendor, idProduct, interface=0, in_ep=0x82, out_ep=0x01):
|
||||
def __init__(self, idVendor, idProduct, interface=0, in_ep=None, out_ep=None):
|
||||
"""
|
||||
@param idVendor : Vendor ID
|
||||
@param idProduct : Product ID
|
||||
@@ -42,6 +42,23 @@ class Usb(Escpos):
|
||||
self.device.detach_kernel_driver(self.interface)
|
||||
self.device.set_configuration()
|
||||
usb.util.claim_interface(self.device, self.interface)
|
||||
|
||||
cfg = self.device.get_active_configuration()
|
||||
intf = cfg[(0,0)] # first interface
|
||||
if self.in_ep is None:
|
||||
# Attempt to detect IN/OUT endpoint addresses
|
||||
try:
|
||||
is_IN = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
|
||||
is_OUT = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
|
||||
endpoint_in = usb.util.find_descriptor(intf, custom_match=is_IN)
|
||||
endpoint_out = usb.util.find_descriptor(intf, custom_match=is_OUT)
|
||||
self.in_ep = endpoint_in.bEndpointAddress
|
||||
self.out_ep = endpoint_out.bEndpointAddress
|
||||
except usb.core.USBError:
|
||||
# default values for officially supported printers
|
||||
self.in_ep = 0x82
|
||||
self.out_ep = 0x01
|
||||
|
||||
except usb.core.USBError as e:
|
||||
raise HandleDeviceError(e)
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@ index_template = """
|
||||
<body>
|
||||
<h1>Your PosBox is up and running</h1>
|
||||
<p>
|
||||
The PosBox is an hardware adapter that allows you to use
|
||||
The PosBox is a hardware adapter that allows you to use
|
||||
receipt printers and barcode scanners with Odoo's Point of
|
||||
Sale, <b>version 8.0 or later</b>. You can start an <a href='https://www.odoo.com/start'>online free trial</a>,
|
||||
or <a href='https://www.odoo.com/start?download'>download and install</a> it yourself.
|
||||
or <a href='https://www.odoo.com/page/download'>download and install</a> it yourself.
|
||||
</p>
|
||||
<p>
|
||||
For more information on how to setup the Point of Sale with
|
||||
@@ -55,7 +55,10 @@ index_template = """
|
||||
Wi-Fi can be configured by visiting the <a href='/wifi'>Wi-Fi configuration page</a>.
|
||||
</p>
|
||||
<p>
|
||||
The PosBox software installed on this posbox is <b>version 14</b>,
|
||||
If you need to grant remote debugging access to a developer, you can do it <a href='/remote_connect'>here</a>.
|
||||
</p>
|
||||
<p>
|
||||
The PosBox software installed on this posbox is <b>version 15</b>,
|
||||
the posbox version number is independent from Odoo. You can upgrade
|
||||
the software on the <a href='/hw_proxy/upgrade/'>upgrade page</a>.
|
||||
</p>
|
||||
|
||||
@@ -11,6 +11,8 @@ import subprocess
|
||||
import json
|
||||
import werkzeug
|
||||
import werkzeug.wrappers
|
||||
from threading import Lock
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -30,6 +32,11 @@ BANNED_DEVICES = set([
|
||||
# so that 'status' can return the status of all active drivers
|
||||
drivers = {}
|
||||
|
||||
# keep a list of RS-232 devices that have been recognized by a driver,
|
||||
# so other drivers can skip them during probes
|
||||
rs232_devices = {} # {'/path/to/device': 'driver'}
|
||||
rs232_lock = Lock() # must be held to update `rs232_devices`
|
||||
|
||||
class Proxy(http.Controller):
|
||||
|
||||
def get_status(self):
|
||||
@@ -47,7 +54,7 @@ class Proxy(http.Controller):
|
||||
return True
|
||||
|
||||
@http.route('/hw_proxy/status', type='http', auth='none', cors='*')
|
||||
def status_http(self):
|
||||
def status_http(self, debug=None, **kwargs):
|
||||
resp = """
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
@@ -95,6 +102,8 @@ class Proxy(http.Controller):
|
||||
<h2>Connected Devices</h2>
|
||||
<p>The list of connected USB devices as seen by the posbox</p>
|
||||
"""
|
||||
if debug is None:
|
||||
resp += """(<a href="/hw_proxy/status?debug">debug version</a>)"""
|
||||
devices = commands.getoutput("lsusb").split('\n')
|
||||
count = 0
|
||||
resp += "<div class='devices'>\n"
|
||||
@@ -110,6 +119,17 @@ class Proxy(http.Controller):
|
||||
|
||||
resp += "</div>\n</body>\n</html>\n\n"
|
||||
|
||||
if debug is not None:
|
||||
resp += """
|
||||
|
||||
<h3>Debug version</h3>
|
||||
<p><tt>lsusb -v</tt> output:</p>
|
||||
<pre>
|
||||
%s
|
||||
</pre>
|
||||
|
||||
""" % subprocess.check_output('lsusb -v', shell=True)
|
||||
|
||||
return request.make_response(resp,{
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
|
||||
+250
-115
@@ -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.info('Disconnected Scale: %s', 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
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ file_exists() {
|
||||
[[ -f $1 ]];
|
||||
}
|
||||
|
||||
require_command () {
|
||||
type "$1" &> /dev/null || { echo "Command $1 is missing. Install it e.g. with 'apt-get install $1'. Aborting." >&2; exit 1; }
|
||||
}
|
||||
|
||||
require_command kpartx
|
||||
require_command qemu-system-arm
|
||||
require_command zerofree
|
||||
|
||||
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
__file="${__dir}/$(basename "${BASH_SOURCE[0]}")"
|
||||
__base="$(basename ${__file} .sh)"
|
||||
@@ -28,6 +36,7 @@ fi
|
||||
cp -a *raspbian*.img posbox.img
|
||||
|
||||
CLONE_DIR="${OVERWRITE_FILES_BEFORE_INIT_DIR}/home/pi/odoo"
|
||||
rm -rf "${CLONE_DIR}"
|
||||
mkdir "${CLONE_DIR}"
|
||||
git clone -b 8.0 --no-checkout --depth 1 https://github.com/odoo/odoo.git "${CLONE_DIR}"
|
||||
cd "${CLONE_DIR}"
|
||||
@@ -44,7 +53,7 @@ cd "${__dir}"
|
||||
USR_BIN="${OVERWRITE_FILES_BEFORE_INIT_DIR}/usr/bin/"
|
||||
mkdir -p "${USR_BIN}"
|
||||
cd "/tmp"
|
||||
curl 'https://dl.ngrok.com/ngrok_2.0.19_linux_arm.zip' > ngrok.zip
|
||||
curl 'https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip' > ngrok.zip
|
||||
unzip ngrok.zip
|
||||
rm ngrok.zip
|
||||
cd "${__dir}"
|
||||
@@ -68,6 +77,7 @@ START_OF_ROOT_PARTITION=$(fdisk -l posbox.img | tail -n 1 | awk '{print $2}')
|
||||
|
||||
LOOP_MAPPER_PATH=$(kpartx -av posbox.img | tail -n 1 | cut -d ' ' -f 3)
|
||||
LOOP_MAPPER_PATH="/dev/mapper/${LOOP_MAPPER_PATH}"
|
||||
sleep 5
|
||||
|
||||
# resize filesystem
|
||||
e2fsck -f "${LOOP_MAPPER_PATH}" # resize2fs requires clean fs
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
wget 'https://downloads.raspberrypi.org/raspbian_lite_latest' -O raspbian.img.zip
|
||||
unzip raspbian.img.zip
|
||||
wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.1.13-jessie' -O kernel-qemu
|
||||
wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.4.13-jessie' -O kernel-qemu
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ class SaleOrder(models.Model):
|
||||
refund is not directly linked to the SO.
|
||||
"""
|
||||
for order in self:
|
||||
invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id')
|
||||
invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund'])
|
||||
# Search for invoices which have been 'cancelled' (filter_refund = 'modify' in
|
||||
# 'account.invoice.refund')
|
||||
# use like as origin may contains multiple references (e.g. 'SO01, SO02')
|
||||
|
||||
@@ -718,7 +718,7 @@ class Picking(models.Model):
|
||||
|
||||
# check if the quant is matching the operation details
|
||||
if ops.package_id:
|
||||
flag = quant.package_id and bool(QuantPackage.search([('id', 'child_of', [ops.package_id.id])])) or False
|
||||
flag = quant.package_id == ops.package_id
|
||||
else:
|
||||
flag = not quant.package_id.id
|
||||
flag = flag and (ops.owner_id.id == quant.owner_id.id)
|
||||
|
||||
@@ -37,6 +37,7 @@ class TestStockCommon(common.TransactionCase):
|
||||
self.productB = self.ProductObj.create({'name': 'Product B', 'type': 'product'})
|
||||
self.productC = self.ProductObj.create({'name': 'Product C', 'type': 'product'})
|
||||
self.productD = self.ProductObj.create({'name': 'Product D', 'type': 'product'})
|
||||
self.productE = self.ProductObj.create({'name': 'Product E', 'type': 'product'})
|
||||
|
||||
# Configure unit of measure.
|
||||
self.uom_kg = self.UomObj.create({
|
||||
|
||||
@@ -1368,3 +1368,129 @@ class TestStockFlow(TestStockCommon):
|
||||
self.assertEqual(quant.qty, 3.0, 'The quant in customer location for productA has not a quantity of 3.0')
|
||||
# Check that the parent package of the quant is the picking_in_package
|
||||
self.assertEqual(quant.package_id.id, picking_in_package.id, 'The quant in customer location is not in its package created in picking in')
|
||||
|
||||
def test_50_create_in_out_with_product_pack_lines(self):
|
||||
picking_in = self.PickingObj.create({
|
||||
'partner_id': self.partner_delta_id,
|
||||
'picking_type_id': self.picking_type_in,
|
||||
'location_id': self.supplier_location,
|
||||
'location_dest_id': self.stock_location})
|
||||
self.MoveObj.create({
|
||||
'name': self.productE.name,
|
||||
'product_id': self.productE.id,
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': self.productE.uom_id.id,
|
||||
'picking_id': picking_in.id,
|
||||
'location_id': self.supplier_location,
|
||||
'location_dest_id': self.stock_location})
|
||||
|
||||
picking_in.action_confirm()
|
||||
picking_in.action_assign()
|
||||
pack_obj = self.env['stock.quant.package']
|
||||
pack1 = pack_obj.create({'name': 'PACKINOUTTEST1'})
|
||||
pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'})
|
||||
picking_in.pack_operation_ids[0].result_package_id = pack1
|
||||
picking_in.pack_operation_ids[0].product_qty = 4
|
||||
packop2 = picking_in.pack_operation_ids[0].copy()
|
||||
packop2.product_qty = 6
|
||||
packop2.result_package_id = pack2
|
||||
picking_in.do_transfer()
|
||||
self.assertEqual(sum([x.qty for x in picking_in.move_lines[0].quant_ids]), 10.0, 'Expecting 10 pieces in stock')
|
||||
#check the quants are in the package
|
||||
self.assertEqual(sum(x.qty for x in pack1.quant_ids), 4.0, 'Pack 1 should have 4 pieces')
|
||||
self.assertEqual(sum(x.qty for x in pack2.quant_ids), 6.0, 'Pack 2 should have 6 pieces')
|
||||
picking_out = self.PickingObj.create({
|
||||
'partner_id': self.partner_agrolite_id,
|
||||
'picking_type_id': self.picking_type_out,
|
||||
'location_id': self.stock_location,
|
||||
'location_dest_id': self.customer_location})
|
||||
self.MoveObj.create({
|
||||
'name': self.productE.name,
|
||||
'product_id': self.productE.id,
|
||||
'product_uom_qty': 3,
|
||||
'product_uom': self.productE.uom_id.id,
|
||||
'picking_id': picking_out.id,
|
||||
'location_id': self.stock_location,
|
||||
'location_dest_id': self.customer_location})
|
||||
picking_out.action_confirm()
|
||||
picking_out.action_assign()
|
||||
packout1 = picking_out.pack_operation_ids[0]
|
||||
packout2 = picking_out.pack_operation_ids[0].copy()
|
||||
packout1.product_qty = 2
|
||||
packout1.package_id = pack1
|
||||
packout2.package_id = pack2
|
||||
packout2.product_qty = 1
|
||||
picking_out.do_transfer()
|
||||
#Check there are no negative quants
|
||||
neg_quants = self.env['stock.quant'].search([('product_id', '=', self.productE.id), ('qty', '<', 0.0)])
|
||||
self.assertEqual(len(neg_quants), 0, 'There are negative quants!')
|
||||
self.assertEqual(len(picking_out.move_lines[0].linked_move_operation_ids), 2, 'We should have 2 links in the matching between the move and the operations')
|
||||
self.assertEqual(len(picking_out.move_lines[0].quant_ids), 2, 'We should have exactly 2 quants in the end')
|
||||
|
||||
def test_60_create_in_out_with_product_pack_lines(self):
|
||||
picking_in = self.PickingObj.create({
|
||||
'partner_id': self.partner_delta_id,
|
||||
'picking_type_id': self.picking_type_in,
|
||||
'location_id': self.supplier_location,
|
||||
'location_dest_id': self.stock_location})
|
||||
self.MoveObj.create({
|
||||
'name': self.productE.name,
|
||||
'product_id': self.productE.id,
|
||||
'product_uom_qty': 200,
|
||||
'product_uom': self.productE.uom_id.id,
|
||||
'picking_id': picking_in.id,
|
||||
'location_id': self.supplier_location,
|
||||
'location_dest_id': self.stock_location})
|
||||
|
||||
picking_in.action_confirm()
|
||||
picking_in.action_assign()
|
||||
pack_obj = self.env['stock.quant.package']
|
||||
pack1 = pack_obj.create({'name': 'PACKINOUTTEST1'})
|
||||
pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'})
|
||||
picking_in.pack_operation_ids[0].result_package_id = pack1
|
||||
picking_in.pack_operation_ids[0].product_qty = 120
|
||||
packop2 = picking_in.pack_operation_ids[0].copy()
|
||||
packop2.product_qty = 80
|
||||
packop2.result_package_id = pack2
|
||||
picking_in.do_transfer()
|
||||
self.assertEqual(sum([x.qty for x in picking_in.move_lines[0].quant_ids]), 200.0, 'Expecting 200 pieces in stock')
|
||||
#check the quants are in the package
|
||||
self.assertEqual(sum(x.qty for x in pack1.quant_ids), 120, 'Pack 1 should have 120 pieces')
|
||||
self.assertEqual(sum(x.qty for x in pack2.quant_ids), 80, 'Pack 2 should have 80 pieces')
|
||||
picking_out = self.PickingObj.create({
|
||||
'partner_id': self.partner_agrolite_id,
|
||||
'picking_type_id': self.picking_type_out,
|
||||
'location_id': self.stock_location,
|
||||
'location_dest_id': self.customer_location})
|
||||
self.MoveObj.create({
|
||||
'name': self.productE.name,
|
||||
'product_id': self.productE.id,
|
||||
'product_uom_qty': 200,
|
||||
'product_uom': self.productE.uom_id.id,
|
||||
'picking_id': picking_out.id,
|
||||
'location_id': self.stock_location,
|
||||
'location_dest_id': self.customer_location})
|
||||
picking_out.action_confirm()
|
||||
picking_out.action_assign()
|
||||
#Convert entire packs into taking out of packs
|
||||
packout0 = picking_out.pack_operation_ids[0]
|
||||
packout1 = picking_out.pack_operation_ids[1]
|
||||
packout0.write({
|
||||
'package_id': pack1.id,
|
||||
'product_id': self.productE.id,
|
||||
'product_qty': 120.0,
|
||||
'product_uom_id': self.productE.uom_id.id,
|
||||
})
|
||||
packout1.write({
|
||||
'package_id': pack2.id,
|
||||
'product_id': self.productE.id,
|
||||
'product_qty': 80.0,
|
||||
'product_uom_id': self.productE.uom_id.id,
|
||||
})
|
||||
picking_out.do_transfer()
|
||||
#Check there are no negative quants
|
||||
neg_quants = self.env['stock.quant'].search([('product_id', '=', self.productE.id), ('qty', '<', 0.0)])
|
||||
self.assertEqual(len(neg_quants), 0, 'There are negative quants!')
|
||||
# We should also make sure that when matching stock moves with pack operations, it takes the correct
|
||||
self.assertEqual(len(picking_out.move_lines[0].linked_move_operation_ids), 2, 'We should only have 2 links beween the move and the 2 operations')
|
||||
self.assertEqual(len(picking_out.move_lines[0].quant_ids), 2, 'We should have exactly 2 quants in the end')
|
||||
|
||||
@@ -98,7 +98,8 @@ return Widget.extend({
|
||||
"there is no active filter in the search bar."),
|
||||
}));
|
||||
} else {
|
||||
this['display_' + this.mode]();
|
||||
var chart = this['display_' + this.mode]();
|
||||
chart.tooltip.chartContainer(this.$el[0]);
|
||||
}
|
||||
},
|
||||
display_bar: function () {
|
||||
@@ -182,6 +183,8 @@ return Widget.extend({
|
||||
chart(svg);
|
||||
this.to_remove = chart.update;
|
||||
nv.utils.onWindowResize(chart.update);
|
||||
|
||||
return chart;
|
||||
},
|
||||
display_pie: function () {
|
||||
var data = [],
|
||||
@@ -232,6 +235,8 @@ return Widget.extend({
|
||||
chart(svg);
|
||||
this.to_remove = chart.update;
|
||||
nv.utils.onWindowResize(chart.update);
|
||||
|
||||
return chart;
|
||||
},
|
||||
display_line: function () {
|
||||
if (this.data.length < 2) {
|
||||
@@ -309,7 +314,9 @@ return Widget.extend({
|
||||
|
||||
chart(svg);
|
||||
this.to_remove = chart.update;
|
||||
nv.utils.onWindowResize(chart.update);
|
||||
nv.utils.onWindowResize(chart.update);
|
||||
|
||||
return chart;
|
||||
},
|
||||
destroy: function () {
|
||||
nv.utils.offWindowResize(this.to_remove);
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nvtooltip thead {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Control panel buttons
|
||||
// ------------------------------------------------------------------
|
||||
.o_graph_measures_list {
|
||||
.o-selected-li;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,11 @@ var FieldTextHtmlSimple = widget.extend({
|
||||
return !this.get('value') || this.get('value') === "<p><br/></p>" || !this.get('value').match(/\S/);
|
||||
},
|
||||
commit_value: function () {
|
||||
// leave code view before saving
|
||||
if (this.$content.prev('textarea').length === 1) {
|
||||
var layoutInfo = { 'editor': function() { return this.$content; }.bind(this) };
|
||||
$.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo);
|
||||
}
|
||||
if (this.options['style-inline']) {
|
||||
transcoder.class_to_style(this.$content);
|
||||
transcoder.font_to_img(this.$content);
|
||||
@@ -374,6 +379,11 @@ var FieldTextHtml = widget.extend({
|
||||
this._dirty_flag = false;
|
||||
return this.editor.save();
|
||||
} else if (this._dirty_flag && this.editor && this.editor.buildingBlock) {
|
||||
// leave code view before saving
|
||||
if (this.$content.prev('textarea').length === 1) {
|
||||
var layoutInfo = { 'editor': function() { return this.$content; }.bind(this) };
|
||||
$.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo);
|
||||
}
|
||||
this.editor.buildingBlock.clean_for_save();
|
||||
this.internal_set_value( this.$content.html() );
|
||||
}
|
||||
|
||||
@@ -539,6 +539,8 @@ function prettify_html(html) {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Change Warning: called with dummy arguments to quit code view before saving
|
||||
$.summernote.pluginEvents.codeview = function (event, editor, layoutInfo) {
|
||||
if (layoutInfo.toolbar) {
|
||||
return eventHandler.modules.codeview.toggle(layoutInfo);
|
||||
|
||||
@@ -335,9 +335,9 @@ $('.oe_website_sale').each(function () {
|
||||
$('input.js_variant_change, select.js_variant_change', this).first().trigger('change');
|
||||
});
|
||||
|
||||
var state_options = $("select[name='state_id'] option:not(:first)");
|
||||
var state_options = $("select[name='state_id']:visible option:not(:first)");
|
||||
$(oe_website_sale).on('change', "select[name='country_id']", function () {
|
||||
var select = $("select[name='state_id']");
|
||||
var select = $("select[name='state_id']:visible");
|
||||
var selected_state = select.val();
|
||||
state_options.detach();
|
||||
var displayed_state = state_options.filter("[data-country_id="+($(this).val() || 0)+"]");
|
||||
|
||||
Reference in New Issue
Block a user