Initial commit: Hw packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit a9d00500da
161 changed files with 10506 additions and 0 deletions

9
README.md Normal file
View file

@ -0,0 +1,9 @@
# Hw
This repository contains OCA OCB packages for hw.
## Packages Included
- odoo-bringout-oca-ocb-hw_drivers
- odoo-bringout-oca-ocb-hw_escpos
- odoo-bringout-oca-ocb-hw_posbox_homepage

View file

@ -0,0 +1,53 @@
# Hardware Proxy
Hardware Poxy
=============
This module allows you to remotely use peripherals connected to this server.
This modules only contains the enabling framework. The actual devices drivers
are found in other modules that must be installed separately.
## Installation
```bash
pip install odoo-bringout-oca-ocb-hw_drivers
```
## Dependencies
This addon depends on:
## Manifest Information
- **Name**: Hardware Proxy
- **Version**: N/A
- **Category**: Hidden
- **License**: LGPL-3
- **Installable**: False
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `hw_drivers`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Hw_drivers Module - hw_drivers
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for hw_drivers. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,17 @@
# Controllers
HTTP routes provided by this module.
```mermaid
sequenceDiagram
participant U as User/Client
participant C as Module Controllers
participant O as ORM/Views
U->>C: HTTP GET/POST (routes)
C->>O: ORM operations, render templates
O-->>U: HTML/JSON/PDF
```
Notes
- See files in controllers/ for route definitions.

View file

@ -0,0 +1,3 @@
# Dependencies
No explicit module dependencies declared.

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon hw_drivers or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-ocb-hw_drivers"
# or
uv pip install odoo-bringout-oca-ocb-hw_drivers"
```

View file

@ -0,0 +1,11 @@
# Models
Detected core models and extensions in hw_drivers.
```mermaid
classDiagram
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: hw_drivers. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon hw_drivers
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,8 @@
# Security
This module does not define custom security rules or access controls beyond Odoo defaults.
Default Odoo security applies:
- Base user access through standard groups
- Model access inherited from dependencies
- No custom row-level security rules

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon hw_drivers
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import server_logger
from . import connection_manager
from . import controllers
from . import driver
from . import event_manager
from . import exception_logger
from . import http
from . import interface
from . import main

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Hardware Proxy',
'category': 'Hidden',
'sequence': 6,
'summary': 'Connect the Web Client to Hardware Peripherals',
'website': 'https://www.odoo.com/app/iot',
'description': """
Hardware Poxy
=============
This module allows you to remotely use peripherals connected to this server.
This modules only contains the enabling framework. The actual devices drivers
are found in other modules that must be installed separately.
""",
'installable': False,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
import logging
import subprocess
import requests
from threading import Thread
import time
import urllib3
from odoo.modules.module import get_resource_path
from odoo.addons.hw_drivers.main import iot_devices, manager
from odoo.addons.hw_drivers.tools import helpers
_logger = logging.getLogger(__name__)
class ConnectionManager(Thread):
def __init__(self):
super(ConnectionManager, self).__init__()
self.pairing_code = False
self.pairing_uuid = False
def run(self):
if not helpers.get_odoo_server_url() and not helpers.access_point():
end_time = datetime.now() + timedelta(minutes=5)
while (datetime.now() < end_time):
self._connect_box()
time.sleep(10)
self.pairing_code = False
self.pairing_uuid = False
self._refresh_displays()
def _connect_box(self):
data = {
'jsonrpc': 2.0,
'params': {
'pairing_code': self.pairing_code,
'pairing_uuid': self.pairing_uuid,
}
}
try:
urllib3.disable_warnings()
req = requests.post('https://iot-proxy.odoo.com/odoo-enterprise/iot/connect-box', json=data, verify=False)
result = req.json().get('result', {})
if all(key in result for key in ['pairing_code', 'pairing_uuid']):
self.pairing_code = result['pairing_code']
self.pairing_uuid = result['pairing_uuid']
elif all(key in result for key in ['url', 'token', 'db_uuid', 'enterprise_code']):
self._connect_to_server(result['url'], result['token'], result['db_uuid'], result['enterprise_code'])
except Exception:
_logger.exception('Could not reach iot-proxy.odoo.com')
def _connect_to_server(self, url, token, db_uuid, enterprise_code):
# Save DB URL and token
helpers.save_conf_server(url, token, db_uuid, enterprise_code)
# Notify the DB, so that the kanban view already shows the IoT Box
manager.send_alldevices()
# Restart to checkout the git branch, get a certificate, load the IoT handlers...
helpers.odoo_restart(2)
def _refresh_displays(self):
"""Refresh all displays to hide the pairing code"""
for d in iot_devices:
if iot_devices[d].device_type == 'display':
iot_devices[d].action({
'action': 'display_refresh'
})
connection_manager = ConnectionManager()
connection_manager.daemon = True
connection_manager.start()

View file

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import driver
from . import proxy

View file

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode
import json
import logging
import os
import subprocess
import time
from odoo import http, tools
from odoo.modules.module import get_resource_path
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.main import iot_devices, manager
from odoo.addons.hw_drivers.tools import helpers
_logger = logging.getLogger(__name__)
class DriverController(http.Controller):
@http.route('/hw_drivers/action', type='json', auth='none', cors='*', csrf=False, save_session=False)
def action(self, session_id, device_identifier, data):
"""
This route is called when we want to make a action with device (take picture, printing,...)
We specify in data from which session_id that action is called
And call the action of specific device
"""
iot_device = iot_devices.get(device_identifier)
if iot_device:
iot_device.data['owner'] = session_id
data = json.loads(data)
# Skip the request if it was already executed (duplicated action calls)
iot_idempotent_id = data.get("iot_idempotent_id")
if iot_idempotent_id:
idempotent_session = iot_device._check_idempotency(iot_idempotent_id, session_id)
if idempotent_session:
_logger.info("Ignored request from %s as iot_idempotent_id %s already received from session %s",
session_id, iot_idempotent_id, idempotent_session)
return False
iot_device.action(data)
return True
return False
@http.route('/hw_drivers/check_certificate', type='http', auth='none', cors='*', csrf=False, save_session=False)
def check_certificate(self):
"""
This route is called when we want to check if certificate is up-to-date
Used in iot-box cron.daily, deprecated since image 24_08 but needed for compatibility with the image 24_01
"""
helpers.get_certificate_status()
@http.route('/hw_drivers/event', type='json', auth='none', cors='*', csrf=False, save_session=False)
def event(self, listener):
"""
listener is a dict in witch there are a sessions_id and a dict of device_identifier to listen
"""
req = event_manager.add_request(listener)
# Search for previous events and remove events older than 5 seconds
oldest_time = time.time() - 5
for event in list(event_manager.events):
if event['time'] < oldest_time:
del event_manager.events[0]
continue
if event['device_identifier'] in listener['devices'] and event['time'] > listener['last_event']:
event['session_id'] = req['session_id']
return event
# Wait for new event
if req['event'].wait(50):
req['event'].clear()
req['result']['session_id'] = req['session_id']
return req['result']
@http.route('/hw_drivers/download_logs', type='http', auth='none', cors='*', csrf=False, save_session=False)
def download_logs(self):
"""
Downloads the log file
"""
if tools.config['logfile']:
res = http.send_file(tools.config['logfile'], mimetype="text/plain", as_attachment=True)
res.headers['Cache-Control'] = 'no-cache'
return res

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
proxy_drivers = {}
class ProxyController(http.Controller):
@http.route('/hw_proxy/hello', type='http', auth='none', cors='*')
def hello(self):
return "ping"
@http.route('/hw_proxy/handshake', type='json', auth='none', cors='*')
def handshake(self):
return True
@http.route('/hw_proxy/status_json', type='json', auth='none', cors='*')
def status_json(self):
statuses = {}
for driver in proxy_drivers:
statuses[driver] = proxy_drivers[driver].get_status()
return statuses

View file

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from threading import Thread, Event
from odoo.addons.hw_drivers.main import drivers, iot_devices
from odoo.tools.lru import LRU
class DriverMetaClass(type):
priority = -1
def __new__(cls, clsname, bases, attrs):
newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
newclass.priority += 1
if clsname != 'Driver':
drivers.append(newclass)
return newclass
class Driver(Thread, metaclass=DriverMetaClass):
"""
Hook to register the driver into the drivers list
"""
connection_type = ''
def __init__(self, identifier, device):
super(Driver, self).__init__()
self.dev = device
self.device_identifier = identifier
self.device_name = ''
self.device_connection = ''
self.device_type = ''
self.device_manufacturer = ''
self.data = {'value': ''}
self._actions = {}
self._stopped = Event()
# Least Recently Used (LRU) Cache that will store the idempotent keys already seen.
self._iot_idempotent_ids_cache = LRU(500)
@classmethod
def supported(cls, device):
"""
On specific driver override this method to check if device is supported or not
return True or False
"""
return False
def 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
"""
self._actions[data.get('action', '')](data)
def disconnect(self):
self._stopped.set()
del iot_devices[self.device_identifier]
def _check_idempotency(self, iot_idempotent_id, session_id):
"""
Some IoT requests for the same action might be received several times.
To avoid duplicating the resulting actions, we check if the action was "recently" executed.
If this is the case, we will simply ignore the action
:return: the `session_id` of the same `iot_idempotent_id` if any. False otherwise,
which means that it is the first time that the IoT box received the request with this ID
"""
cache = self._iot_idempotent_ids_cache
if iot_idempotent_id in cache:
return cache[iot_idempotent_id]
cache[iot_idempotent_id] = session_id
return False

View file

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from threading import Event
import time
from odoo.http import request
class EventManager(object):
def __init__(self):
self.events = []
self.sessions = {}
def _delete_expired_sessions(self, max_time=70):
'''
Clears sessions that are no longer called.
:param max_time: time a session can stay unused before being deleted
'''
now = time.time()
expired_sessions = [
session
for session in self.sessions
if now - self.sessions[session]['time_request'] > max_time
]
for session in expired_sessions:
del self.sessions[session]
def add_request(self, listener):
self.session = {
'session_id': listener['session_id'],
'devices': listener['devices'],
'event': Event(),
'result': {},
'time_request': time.time(),
}
self._delete_expired_sessions()
self.sessions[listener['session_id']] = self.session
return self.sessions[listener['session_id']]
def device_changed(self, device):
event = {
**device.data,
'device_identifier': device.device_identifier,
'time': time.time(),
'request_data': json.loads(request.params['data']) if request and 'data' in request.params else None,
}
self.events.append(event)
for session in self.sessions:
if device.device_identifier in self.sessions[session]['devices'] and not self.sessions[session]['event'].is_set():
self.sessions[session]['result'] = event
self.sessions[session]['event'].set()
event_manager = EventManager()

View file

@ -0,0 +1,34 @@
from io import StringIO
import logging
import sys
_logger = logging.getLogger(__name__)
class ExceptionLogger:
"""
Redirect any unhandled python exception to the logger to keep track of them in the log file.
"""
def __init__(self):
self._buffer = StringIO()
def write(self, message):
self._buffer.write(message)
if message.endswith('\n'):
self._flush_buffer()
def _flush_buffer(self):
self._buffer.seek(0)
_logger.error(self._buffer.getvalue().rstrip('\n'))
self._buffer = StringIO() # Reset the buffer
def flush(self):
if self._buffer.tell() > 0:
self._flush_buffer()
def close(self):
self.flush()
sys.stderr = ExceptionLogger()

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
def db_list(force=False, host=None):
return []
odoo.http.db_list = db_list

View file

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from threading import Thread
import time
from odoo.addons.hw_drivers.main import drivers, interfaces, iot_devices
_logger = logging.getLogger(__name__)
class InterfaceMetaClass(type):
def __new__(cls, clsname, bases, attrs):
new_interface = super(InterfaceMetaClass, cls).__new__(cls, clsname, bases, attrs)
if clsname != 'Interface':
interfaces[clsname] = new_interface
return new_interface
class Interface(Thread, metaclass=InterfaceMetaClass):
_loop_delay = 3 # Delay (in seconds) between calls to get_devices or 0 if it should be called only once
_detected_devices = {}
connection_type = ''
def __init__(self):
super(Interface, self).__init__()
self.drivers = sorted([d for d in drivers if d.connection_type == self.connection_type], key=lambda d: d.priority, reverse=True)
def run(self):
while self.connection_type and self.drivers:
self.update_iot_devices(self.get_devices())
if not self._loop_delay:
break
time.sleep(self._loop_delay)
def update_iot_devices(self, devices={}):
added = devices.keys() - self._detected_devices
removed = self._detected_devices - devices.keys()
# keys() returns a dict_keys, and the values of that stay in sync with the
# original dictionary if it changes. This means that get_devices needs to return
# a newly created dictionary every time. If it doesn't do that and reuses the
# same dictionary, this logic won't detect any changes that are made. Could be
# avoided by converting the dict_keys into a regular dict. The current logic
# also can't detect if a device is replaced by a different one with the same
# key. Also, _detected_devices starts out as a class variable but gets turned
# into an instance variable here. It would be better if it was an instance
# variable from the start to avoid confusion.
self._detected_devices = devices.keys()
for identifier in removed:
if identifier in iot_devices:
iot_devices[identifier].disconnect()
_logger.info('Device %s is now disconnected', identifier)
for identifier in added:
for driver in self.drivers:
if driver.supported(devices[identifier]):
_logger.info('Device %s is now connected', identifier)
d = driver(identifier, devices[identifier])
d.daemon = True
iot_devices[identifier] = d
# Start the thread after creating the iot_devices entry so the
# thread can assume the iot_devices entry will exist while it's
# running, at least until the `disconnect` above gets triggered
# when `removed` is not empty.
d.start()
break
def get_devices(self):
raise NotImplementedError()

View file

@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import jinja2
import json
import logging
import netifaces as ni
import os
import subprocess
import threading
import time
import urllib3
from odoo import http
from odoo.addons.hw_drivers.connection_manager import connection_manager
from odoo.addons.hw_drivers.driver import Driver
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.main import iot_devices
from odoo.addons.hw_drivers.tools import helpers
path = os.path.realpath(os.path.join(os.path.dirname(__file__), '../../views'))
loader = jinja2.FileSystemLoader(path)
jinja_env = jinja2.Environment(loader=loader, autoescape=True)
jinja_env.filters["json"] = json.dumps
pos_display_template = jinja_env.get_template('pos_display.html')
_logger = logging.getLogger(__name__)
class DisplayDriver(Driver):
connection_type = 'display'
def __init__(self, identifier, device):
super(DisplayDriver, self).__init__(identifier, device)
self.device_type = 'display'
self.device_connection = 'hdmi'
self.device_name = device['name']
self.event_data = threading.Event()
self.owner = False
self.rendered_html = ''
if self.device_identifier != 'distant_display':
self._x_screen = device.get('x_screen', '0')
self.load_url()
self._actions.update({
'update_url': self._action_update_url,
'display_refresh': self._action_display_refresh,
'take_control': self._action_take_control,
'customer_facing_display': self._action_customer_facing_display,
'get_owner': self._action_get_owner,
})
@classmethod
def supported(cls, device):
return True # All devices with connection_type == 'display' are supported
@classmethod
def get_default_display(cls):
displays = list(filter(lambda d: iot_devices[d].device_type == 'display', iot_devices))
return len(displays) and iot_devices[displays[0]]
def run(self):
while self.device_identifier != 'distant_display' and not self._stopped.is_set():
time.sleep(60)
if self.url != 'http://localhost:8069/point_of_sale/display/' + self.device_identifier:
# Refresh the page every minute
self.call_xdotools('F5')
def update_url(self, url=None):
os.environ['DISPLAY'] = ":0." + self._x_screen
os.environ['XAUTHORITY'] = '/run/lightdm/pi/xauthority'
firefox_env = os.environ.copy()
firefox_env['HOME'] = '/tmp/' + self._x_screen
self.url = url or 'http://localhost:8069/point_of_sale/display/' + self.device_identifier
new_window = subprocess.call(['xdotool', 'search', '--onlyvisible', '--screen', self._x_screen, '--class', 'Firefox'])
subprocess.Popen(['firefox', self.url], env=firefox_env)
if new_window:
self.call_xdotools('F11')
def load_url(self):
url = None
if helpers.get_odoo_server_url():
# disable certifiacte verification
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE')
try:
response = http.request('GET', "%s/iot/box/%s/display_url" % (helpers.get_odoo_server_url(), helpers.get_mac_address()))
if response.status == 200:
data = json.loads(response.data.decode('utf8'))
url = data[self.device_identifier]
except json.decoder.JSONDecodeError:
url = response.data.decode('utf8')
except Exception:
pass
return self.update_url(url)
def call_xdotools(self, keystroke):
os.environ['DISPLAY'] = ":0." + self._x_screen
os.environ['XAUTHORITY'] = "/run/lightdm/pi/xauthority"
try:
subprocess.call(['xdotool', 'search', '--sync', '--onlyvisible', '--screen', self._x_screen, '--class', 'Firefox', 'key', keystroke])
return "xdotool succeeded in stroking " + keystroke
except:
return "xdotool threw an error, maybe it is not installed on the IoTBox"
def update_customer_facing_display(self, origin, html=None):
if origin == self.owner:
self.rendered_html = html
self.event_data.set()
def get_serialized_order(self):
# IMPLEMENTATION OF LONGPOLLING
# Times out 2 seconds before the JS request does
if self.event_data.wait(28):
self.event_data.clear()
return {'rendered_html': self.rendered_html}
return {'rendered_html': False}
def take_control(self, new_owner, html=None):
# ALLOW A CASHIER TO TAKE CONTROL OVER THE POSBOX, IN CASE OF MULTIPLE CASHIER PER DISPLAY
self.owner = new_owner
self.rendered_html = html
self.data = {
'value': '',
'owner': self.owner,
}
event_manager.device_changed(self)
self.event_data.set()
def _action_update_url(self, data):
if self.device_identifier != 'distant_display':
self.update_url(data.get('url'))
def _action_display_refresh(self, data):
if self.device_identifier != 'distant_display':
self.call_xdotools('F5')
def _action_take_control(self, data):
self.take_control(self.data.get('owner'), data.get('html'))
def _action_customer_facing_display(self, data):
self.update_customer_facing_display(self.data.get('owner'), data.get('html'))
def _action_get_owner(self, data):
self.data = {
'value': '',
'owner': self.owner,
}
event_manager.device_changed(self)
class DisplayController(http.Controller):
@http.route('/hw_proxy/display_refresh', type='json', auth='none', cors='*')
def display_refresh(self):
display = DisplayDriver.get_default_display()
if display and display.device_identifier != 'distant_display':
return display.call_xdotools('F5')
@http.route('/hw_proxy/customer_facing_display', type='json', auth='none', cors='*')
def customer_facing_display(self, html=None):
display = DisplayDriver.get_default_display()
if display:
display.update_customer_facing_display(http.request.httprequest.remote_addr, html)
return {'status': 'updated'}
return {'status': 'failed'}
@http.route('/hw_proxy/take_control', type='json', auth='none', cors='*')
def take_control(self, html=None):
display = DisplayDriver.get_default_display()
if display:
display.take_control(http.request.httprequest.remote_addr, html)
return {
'status': 'success',
'message': 'You now have access to the display',
}
@http.route('/hw_proxy/test_ownership', type='json', auth='none', cors='*')
def test_ownership(self):
display = DisplayDriver.get_default_display()
if display and display.owner == http.request.httprequest.remote_addr:
return {'status': 'OWNER'}
return {'status': 'NOWNER'}
@http.route(['/point_of_sale/get_serialized_order', '/point_of_sale/get_serialized_order/<string:display_identifier>'], type='json', auth='none')
def get_serialized_order(self, display_identifier=None):
if display_identifier:
display = iot_devices.get(display_identifier)
else:
display = DisplayDriver.get_default_display()
if display:
return display.get_serialized_order()
return {
'rendered_html': False,
'error': "No display found",
}
@http.route(['/point_of_sale/display', '/point_of_sale/display/<string:display_identifier>'], type='http', auth='none')
def display(self, display_identifier=None):
cust_js = None
interfaces = ni.interfaces()
with open(os.path.join(os.path.dirname(__file__), "../../static/src/js/worker.js")) as js:
cust_js = js.read()
display_ifaces = []
for iface_id in interfaces:
if 'wlan' in iface_id or 'eth' in iface_id:
iface_obj = ni.ifaddresses(iface_id)
ifconfigs = iface_obj.get(ni.AF_INET, [])
essid = helpers.get_ssid()
for conf in ifconfigs:
if conf.get('addr'):
display_ifaces.append({
'iface_id': iface_id,
'essid': essid,
'addr': conf.get('addr'),
'icon': 'sitemap' if 'eth' in iface_id else 'wifi',
})
if not display_identifier:
display_identifier = DisplayDriver.get_default_display().device_identifier
return pos_display_template.render({
'title': "Odoo -- Point of Sale",
'breadcrumb': 'POS Client display',
'cust_js': cust_js,
'display_ifaces': display_ifaces,
'display_identifier': display_identifier,
'pairing_code': connection_manager.pairing_code,
})

View file

@ -0,0 +1,384 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
import evdev
import json
import logging
from lxml import etree
import os
from pathlib import Path
from queue import Queue, Empty
import re
import subprocess
from threading import Lock
import time
import urllib3
from usb import util
from odoo import http, _
from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
from odoo.addons.hw_drivers.driver import Driver
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.main import iot_devices
from odoo.addons.hw_drivers.tools import helpers
_logger = logging.getLogger(__name__)
xlib = ctypes.cdll.LoadLibrary('libX11.so.6')
class KeyboardUSBDriver(Driver):
# The list of devices can be found in /proc/bus/input/devices
# or "ls -l /dev/input/by-path"
# Note: the user running "evdev" commands must be inside the "input" group
# Each device's input will correspond to a file in /dev/input/event*
# The exact file can be found by looking at the "Handlers" line in /proc/bus/input/devices
# Example: "H: Handlers=sysrq kbd leds event0" -> The file used is /dev/input/event0
# If you read the file "/dev/input/event0" you will get the input from the device in real time
# One usb device can have multiple associated event files (like a foot pedal which has 3 event files)
connection_type = 'usb'
keyboard_layout_groups = []
available_layouts = []
input_devices = []
def __init__(self, identifier, device):
if not hasattr(KeyboardUSBDriver, 'display'):
os.environ['XAUTHORITY'] = "/run/lightdm/pi/xauthority"
KeyboardUSBDriver.display = xlib.XOpenDisplay(bytes(":0.0", "utf-8"))
super(KeyboardUSBDriver, self).__init__(identifier, device)
self.device_connection = 'direct'
self.device_name = self._set_name()
self._actions.update({
'update_layout': self._update_layout,
'update_is_scanner': self._save_is_scanner,
'': self._action_default,
})
# from https://github.com/xkbcommon/libxkbcommon/blob/master/test/evdev-scancodes.h
self._scancode_to_modifier = {
42: 'left_shift',
54: 'right_shift',
58: 'caps_lock',
69: 'num_lock',
100: 'alt_gr', # right alt
}
self._tracked_modifiers = {modifier: False for modifier in self._scancode_to_modifier.values()}
if not KeyboardUSBDriver.available_layouts:
KeyboardUSBDriver.load_layouts_list()
KeyboardUSBDriver.send_layouts_list()
for evdev_device in [evdev.InputDevice(path) for path in evdev.list_devices()]:
if (device.idVendor == evdev_device.info.vendor) and (device.idProduct == evdev_device.info.product):
self.input_devices.append(evdev_device)
self._set_device_type('scanner') if self._is_scanner() else self._set_device_type()
@classmethod
def supported(cls, device):
for cfg in device:
for itf in cfg:
if itf.bInterfaceClass == 3 and itf.bInterfaceProtocol != 2:
device.interface_protocol = itf.bInterfaceProtocol
return True
return False
@classmethod
def get_status(self):
"""Allows `hw_proxy.Proxy` to retrieve the status of the scanners"""
status = 'connected' if any(iot_devices[d].device_type == "scanner" for d in iot_devices) else 'disconnected'
return {'status': status, 'messages': ''}
@classmethod
def send_layouts_list(cls):
server = helpers.get_odoo_server_url()
if server:
urllib3.disable_warnings()
pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
server = server + '/iot/keyboard_layouts'
try:
pm.request('POST', server, fields={'available_layouts': json.dumps(cls.available_layouts)})
except Exception:
_logger.exception('Could not reach configured server to send available layouts')
@classmethod
def load_layouts_list(cls):
tree = etree.parse("/usr/share/X11/xkb/rules/base.xml", etree.XMLParser(ns_clean=True, recover=True))
layouts = tree.xpath("//layout")
for layout in layouts:
layout_name = layout.xpath("./configItem/name")[0].text
layout_description = layout.xpath("./configItem/description")[0].text
KeyboardUSBDriver.available_layouts.append({
'name': layout_description,
'layout': layout_name,
})
for variant in layout.xpath("./variantList/variant"):
variant_name = variant.xpath("./configItem/name")[0].text
variant_description = variant.xpath("./configItem/description")[0].text
KeyboardUSBDriver.available_layouts.append({
'name': variant_description,
'layout': layout_name,
'variant': variant_name,
})
def _set_name(self):
try:
manufacturer = util.get_string(self.dev, self.dev.iManufacturer)
product = util.get_string(self.dev, self.dev.iProduct)
if manufacturer and product:
return re.sub(r"[^\w \-+/*&]", '', "%s - %s" % (manufacturer, product))
except ValueError as e:
_logger.warning(e)
return _('Unknown input device')
def run(self):
try:
for device in self.input_devices:
for event in device.read_loop():
if self._stopped.is_set():
break
if event.type == evdev.ecodes.EV_KEY:
data = evdev.categorize(event)
modifier_name = self._scancode_to_modifier.get(data.scancode)
if modifier_name:
if modifier_name in ('caps_lock', 'num_lock'):
if data.keystate == 1:
self._tracked_modifiers[modifier_name] = not self._tracked_modifiers[modifier_name]
else:
self._tracked_modifiers[modifier_name] = bool(data.keystate) # 1 for keydown, 0 for keyup
elif data.keystate == 1:
self.key_input(data.scancode)
except Exception as err:
_logger.warning(err)
def _change_keyboard_layout(self, new_layout):
"""Change the layout of the current device to what is specified in
new_layout.
Args:
new_layout (dict): A dict containing two keys:
- layout (str): The layout code
- variant (str): An optional key to represent the variant of the
selected layout
"""
if hasattr(self, 'keyboard_layout'):
KeyboardUSBDriver.keyboard_layout_groups.remove(self.keyboard_layout)
if new_layout:
self.keyboard_layout = new_layout.get('layout') or 'us'
if new_layout.get('variant'):
self.keyboard_layout += "(%s)" % new_layout['variant']
else:
self.keyboard_layout = 'us'
KeyboardUSBDriver.keyboard_layout_groups.append(self.keyboard_layout)
subprocess.call(["setxkbmap", "-display", ":0.0", ",".join(KeyboardUSBDriver.keyboard_layout_groups)])
# Close then re-open display to refresh the mapping
xlib.XCloseDisplay(KeyboardUSBDriver.display)
KeyboardUSBDriver.display = xlib.XOpenDisplay(bytes(":0.0", "utf-8"))
def save_layout(self, layout):
"""Save the layout to a file on the box to read it when restarting it.
We need that in order to keep the selected layout after a reboot.
Args:
new_layout (dict): A dict containing two keys:
- layout (str): The layout code
- variant (str): An optional key to represent the variant of the
selected layout
"""
file_path = helpers.path_file('odoo-keyboard-layouts.conf')
if file_path.exists():
data = json.loads(file_path.read_text())
else:
data = {}
data[self.device_identifier] = layout
helpers.write_file('odoo-keyboard-layouts.conf', json.dumps(data))
def load_layout(self):
"""Read the layout from the saved filed and set it as current layout.
If no file or no layout is found we use 'us' by default.
"""
file_path = helpers.path_file('odoo-keyboard-layouts.conf')
if file_path.exists():
data = json.loads(file_path.read_text())
layout = data.get(self.device_identifier, {'layout': 'us'})
else:
layout = {'layout': 'us'}
self._change_keyboard_layout(layout)
def _action_default(self, data):
self.data['value'] = ''
event_manager.device_changed(self)
def _is_scanner(self):
"""Read the device type from the saved filed and set it as current type.
If no file or no device type is found we try to detect it automatically.
"""
device_name = self.device_name.lower()
scanner_name = ['barcode', 'scanner', 'reader']
is_scanner = any(x in device_name for x in scanner_name) or self.dev.interface_protocol == '0'
file_path = helpers.path_file('odoo-keyboard-is-scanner.conf')
if file_path.exists():
data = json.loads(file_path.read_text())
is_scanner = data.get(self.device_identifier, {}).get('is_scanner', is_scanner)
return is_scanner
def _keyboard_input(self, scancode):
"""Deal with a keyboard input. Send the character corresponding to the
pressed key represented by its scancode to the connected Odoo instance.
Args:
scancode (int): The scancode of the pressed key.
"""
self.data['value'] = self._scancode_to_char(scancode)
if self.data['value']:
event_manager.device_changed(self)
def _barcode_scanner_input(self, scancode):
"""Deal with a barcode scanner input. Add the new character scanned to
the current barcode or complete the barcode if "Return" is pressed.
When a barcode is completed, two tasks are performed:
- Send a device_changed update to the event manager to notify the
listeners that the value has changed (used in Enterprise).
- Add the barcode to the list barcodes that are being queried in
Community.
Args:
scancode (int): The scancode of the pressed key.
"""
if scancode == 28: # Return
self.data['value'] = self._current_barcode
event_manager.device_changed(self)
self._barcodes.put((time.time(), self._current_barcode))
self._current_barcode = ''
else:
self._current_barcode += self._scancode_to_char(scancode)
def _save_is_scanner(self, data):
"""Save the type of device.
We need that in order to keep the selected type of device after a reboot.
"""
is_scanner = {'is_scanner': data.get('is_scanner')}
file_path = helpers.path_file('odoo-keyboard-is-scanner.conf')
if file_path.exists():
data = json.loads(file_path.read_text())
else:
data = {}
data[self.device_identifier] = is_scanner
helpers.write_file('odoo-keyboard-is-scanner.conf', json.dumps(data))
self._set_device_type('scanner') if is_scanner.get('is_scanner') else self._set_device_type()
def _update_layout(self, data):
layout = {
'layout': data.get('layout'),
'variant': data.get('variant'),
}
self._change_keyboard_layout(layout)
self.save_layout(layout)
def _set_device_type(self, device_type='keyboard'):
"""Modify the device type between 'keyboard' and 'scanner'
Args:
type (string): Type wanted to switch
"""
if device_type == 'scanner':
self.device_type = 'scanner'
self.key_input = self._barcode_scanner_input
self._barcodes = Queue()
self._current_barcode = ''
for device in self.input_devices:
device.grab()
self.read_barcode_lock = Lock()
else:
self.device_type = 'keyboard'
self.key_input = self._keyboard_input
self.load_layout()
def _scancode_to_char(self, scancode):
"""Translate a received scancode to a character depending on the
selected keyboard layout and the current state of the keyboard's
modifiers.
Args:
scancode (int): The scancode of the pressed key, to be translated to
a character
Returns:
str: The translated scancode.
"""
# Scancode -> Keysym : Depends on the keyboard layout
group = KeyboardUSBDriver.keyboard_layout_groups.index(self.keyboard_layout)
modifiers = self._get_active_modifiers(scancode)
keysym = ctypes.c_int(xlib.XkbKeycodeToKeysym(KeyboardUSBDriver.display, scancode + 8, group, modifiers))
# Translate Keysym to a character
key_pressed = ctypes.create_string_buffer(5)
xlib.XkbTranslateKeySym(KeyboardUSBDriver.display, ctypes.byref(keysym), 0, ctypes.byref(key_pressed), 5, ctypes.byref(ctypes.c_int()))
if key_pressed.value:
return key_pressed.value.decode('utf-8')
return ''
def _get_active_modifiers(self, scancode):
"""Get the state of currently active modifiers.
Args:
scancode (int): The scancode of the key being translated
Returns:
int: The current state of the modifiers:
0 -- Lowercase
1 -- Highercase or (NumLock + key pressed on keypad)
2 -- AltGr
3 -- Highercase + AltGr
"""
modifiers = 0
uppercase = (self._tracked_modifiers['right_shift'] or self._tracked_modifiers['left_shift']) ^ self._tracked_modifiers['caps_lock']
if uppercase or (scancode in [71, 72, 73, 75, 76, 77, 79, 80, 81, 82, 83] and self._tracked_modifiers['num_lock']):
modifiers += 1
if self._tracked_modifiers['alt_gr']:
modifiers += 2
return modifiers
def read_next_barcode(self):
"""Get the value of the last barcode that was scanned but not sent yet
and not older than 5 seconds. This function is used in Community, when
we don't have access to the IoTLongpolling.
Returns:
str: The next barcode to be read or an empty string.
"""
# Previous query still running, stop it by sending a fake barcode
if self.read_barcode_lock.locked():
self._barcodes.put((time.time(), ""))
with self.read_barcode_lock:
try:
timestamp, barcode = self._barcodes.get(True, 55)
if timestamp > time.time() - 5:
return barcode
except Empty:
return ''
proxy_drivers['scanner'] = KeyboardUSBDriver
class KeyboardUSBController(http.Controller):
@http.route('/hw_proxy/scanner', type='json', auth='none', cors='*')
def get_barcode(self):
scanners = [iot_devices[d] for d in iot_devices if iot_devices[d].device_type == "scanner"]
if scanners:
return scanners[0].read_next_barcode()
time.sleep(5)
return None

View file

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
import platform
import json
from passlib.context import CryptContext
from odoo import http
from odoo.tools.config import config
_logger = logging.getLogger(__name__)
try:
import PyKCS11
except ImportError:
PyKCS11 = None
_logger.error('Could not import library PyKCS11')
crypt_context = CryptContext(schemes=['pbkdf2_sha512'])
class EtaUsbController(http.Controller):
def _is_access_token_valid(self, access_token):
stored_hash = config.get('proxy_access_token')
if not stored_hash:
# empty password/hash => authentication forbidden
return False
return crypt_context.verify(access_token, stored_hash)
@http.route('/hw_l10n_eg_eta/certificate', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST'])
def eta_certificate(self, pin, access_token):
"""
Gets the certificate from the token and returns it to the main odoo instance so that we can prepare the
cades-bes object on the main odoo instance rather than this middleware
@param pin: pin of the token
@param access_token: token shared with the main odoo instance
"""
if not PyKCS11:
return self._get_error_template('no_pykcs11')
if not self._is_access_token_valid(access_token):
return self._get_error_template('unauthorized')
session, error = self._get_session(pin)
if error:
return error
try:
cert = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_CERTIFICATE)])[0]
cert_bytes = bytes(session.getAttributeValue(cert, [PyKCS11.CKA_VALUE])[0])
payload = {
'certificate': base64.b64encode(cert_bytes).decode()
}
return json.dumps(payload)
except Exception as ex:
_logger.exception('Error while getting ETA certificate')
return self._get_error_template(str(ex))
finally:
session.logout()
session.closeSession()
@http.route('/hw_l10n_eg_eta/sign', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST'])
def eta_sign(self, pin, access_token, invoices):
"""
Check if the access_token is valid and sign the invoices accessing the usb key with the pin.
@param pin: pin of the token
@param access_token: token shared with the main odoo instance
@param invoices: dictionary of invoices. Keys are invoices ids, value are the base64 encoded binaries to sign
"""
if not PyKCS11:
return self._get_error_template('no_pykcs11')
if not self._is_access_token_valid(access_token):
return self._get_error_template('unauthorized')
session, error = self._get_session(pin)
if error:
return error
try:
cert = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_CERTIFICATE)])[0]
cert_id = session.getAttributeValue(cert, [PyKCS11.CKA_ID])[0]
priv_key = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_PRIVATE_KEY), (PyKCS11.CKA_ID, cert_id)])[0]
invoice_dict = dict()
invoices = json.loads(invoices)
for invoice, eta_inv in invoices.items():
to_sign = base64.b64decode(eta_inv)
signed_data = session.sign(priv_key, to_sign, PyKCS11.Mechanism(PyKCS11.CKM_SHA256_RSA_PKCS))
invoice_dict[invoice] = base64.b64encode(bytes(signed_data)).decode()
payload = {
'invoices': json.dumps(invoice_dict),
}
return json.dumps(payload)
except Exception as ex:
_logger.exception('Error while signing invoices')
return self._get_error_template(str(ex))
finally:
session.logout()
session.closeSession()
def _get_session(self, pin):
session = False
lib, error = self.get_crypto_lib()
if error:
return session, error
try:
pkcs11 = PyKCS11.PyKCS11Lib()
pkcs11.load(pkcs11dll_filename=lib)
except PyKCS11.PyKCS11Error:
return session, self._get_error_template('missing_dll')
slots = pkcs11.getSlotList(tokenPresent=True)
if not slots:
return session, self._get_error_template('no_drive')
if len(slots) > 1:
return session, self._get_error_template('multiple_drive')
try:
session = pkcs11.openSession(slots[0], PyKCS11.CKF_SERIAL_SESSION | PyKCS11.CKF_RW_SESSION)
session.login(pin)
except Exception as ex:
error = self._get_error_template(str(ex))
return session, error
def get_crypto_lib(self):
error = lib = False
system = platform.system()
if system == 'Linux':
lib = '/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so'
elif system == 'Windows':
lib = 'C:/Windows/System32/eps2003csp11.dll'
elif system == 'Darwin':
lib = '/Library/OpenSC/lib/onepin-opensc-pkcs11.so'
else:
error = self._get_error_template('unsupported_system')
return lib, error
def _get_error_template(self, error_str):
return json.dumps({
'error': error_str,
})

View file

@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import serial
import time
import struct
import json
from functools import reduce
from odoo import http
from odoo.addons.hw_drivers.iot_handlers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection
from odoo.addons.hw_drivers.main import iot_devices
_logger = logging.getLogger(__name__)
TremolG03Protocol = SerialProtocol(
name='Tremol G03',
baudrate=115200,
bytesize=serial.EIGHTBITS,
stopbits=serial.STOPBITS_ONE,
parity=serial.PARITY_NONE,
timeout=4,
writeTimeout=0.2,
measureRegexp=None,
statusRegexp=None,
commandTerminator=b'',
commandDelay=0.2,
measureDelay=3,
newMeasureDelay=0.2,
measureCommand=b'',
emptyAnswerValid=False,
)
STX = 0x02
ETX = 0x0A
ACK = 0x06
NACK = 0x15
# Dictionary defining the output size of expected from various commands
COMMAND_OUTPUT_SIZE = {
0x30: 7,
0x31: 7,
0x38: 157,
0x39: 155,
0x60: 40,
0x68: 23,
}
FD_ERRORS = {
0x30: 'OK',
0x32: 'Registers overflow',
0x33: 'Clock failure or incorrect date & time',
0x34: 'Opened fiscal receipt',
0x39: 'Incorrect password',
0x3b: '24 hours block - missing Z report',
0x3d: 'Interrupt power supply in fiscal receipt (one time until status is read)',
0x3e: 'Overflow EJ',
0x3f: 'Insufficient conditions',
}
COMMAND_ERRORS = {
0x30: 'OK',
0x31: 'Invalid command',
0x32: 'Illegal command',
0x33: 'Z daily report is not zero',
0x34: 'Syntax error',
0x35: 'Input registers orverflow',
0x36: 'Zero input registers',
0x37: 'Unavailable transaction for correction',
0x38: 'Insufficient amount on hand',
}
class TremolG03Driver(SerialDriver):
"""Driver for the Kenyan Tremol G03 fiscal device."""
_protocol = TremolG03Protocol
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'fiscal_data_module'
self.message_number = 0
@classmethod
def get_default_device(cls):
fiscal_devices = list(filter(lambda d: iot_devices[d].device_type == 'fiscal_data_module', iot_devices))
return len(fiscal_devices) and iot_devices[fiscal_devices[0]]
@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:
protocol = cls._protocol
with serial_connection(device['identifier'], protocol) as connection:
connection.write(b'\x09')
time.sleep(protocol.commandDelay)
response = connection.read(1)
if response == b'\x40':
return True
except serial.serialutil.SerialTimeoutException:
pass
except Exception:
_logger.exception('Error while probing %s with protocol %s', device, protocol.name)
# ----------------
# HELPERS
# ----------------
@staticmethod
def generate_checksum(message):
""" Generate the checksum bytes for the bytes provided.
:param message: bytes representing the part of the message from which the checksum is calculated
:returns: two checksum bytes calculated from the message
This checksum is calculated as:
1) XOR of all bytes of the bytes
2) Conversion of the one XOR byte into the two bytes of the checksum by
adding 30h to each half-byte of the XOR
eg. to_check = \x12\x23\x34\x45\x56
XOR of all bytes in to_check = \x16
checksum generated as \x16 -> \x31 \x36
"""
xor = reduce(lambda a, b: a ^ b, message)
return bytes([(xor >> 4) + 0x30, (xor & 0xf) + 0x30])
# ----------------
# COMMUNICATION
# ----------------
def send(self, msgs):
""" Send and receive messages to/from the fiscal device over serial connection
Generate the wrapped message from the msgs and send them to the device.
The wrapping contains the <STX> (starting byte) <LEN> (length byte)
and <NBL> (message number byte) at the start and two <CS> (checksum
bytes), and the <ETX> line-feed byte at the end.
:param msgs: A list of byte strings representing the <CMD> and <DATA>
components of the serial message.
:return: A list of the responses (if any) from the device. If the
response is an ack, it wont be part of this list.
"""
with self._device_lock:
replies = []
for msg in msgs:
self.message_number += 1
core_message = struct.pack('BB%ds' % (len(msg)), len(msg) + 34, self.message_number + 32, msg)
request = struct.pack('B%ds2sB' % (len(core_message)), STX, core_message, self.generate_checksum(core_message), ETX)
time.sleep(self._protocol.commandDelay)
self._connection.write(request)
# If we know the expected output size, we can set the read
# buffer to match the size of the output.
output_size = COMMAND_OUTPUT_SIZE.get(msg[0])
if output_size:
try:
response = self._connection.read(output_size)
except serial.serialutil.SerialTimeoutException:
_logger.exception('Timeout error while reading response to command %s', msg)
self.data['status'] = "Device timeout error"
else:
time.sleep(self._protocol.measureDelay)
response = self._connection.read_all()
if not response:
self.data['status'] = "No response"
_logger.error("Sent request: %s,\n Received no response", request)
self.abort_post()
break
if response[0] == ACK:
# In the case where either byte is not 0x30, there has been an error
if response[2] != 0x30 or response[3] != 0x30:
self.data['status'] = response[2:4].decode('cp1251')
_logger.error(
"Sent request: %s,\n Received fiscal device error: %s \n Received command error: %s",
request, FD_ERRORS.get(response[2], 'Unknown fiscal device error'),
COMMAND_ERRORS.get(response[3], 'Unknown command error'),
)
self.abort_post()
break
replies.append('')
elif response[0] == NACK:
self.data['status'] = "Received NACK"
_logger.error("Sent request: %s,\n Received NACK \x15", request)
self.abort_post()
break
elif response[0] == 0x02:
self.data['status'] = "ok"
size = response[1] - 35
reply = response[4:4 + size]
replies.append(reply.decode('cp1251'))
return {'replies': replies, 'status': self.data['status']}
def abort_post(self):
""" Cancel the posting of the invoice
In the event of an error, it is better to try to cancel the posting of
the invoice, since the state of the invoice on the device will remain
open otherwise, blocking further invoices being sent.
"""
self.message_number += 1
abort = struct.pack('BBB', 35, self.message_number + 32, 0x39)
request = struct.pack('B3s2sB', STX, abort, self.generate_checksum(abort), ETX)
self._connection.write(request)
response = self._connection.read(COMMAND_OUTPUT_SIZE[0x39])
if response and response[0] == 0x02:
self.data['status'] += "\n The invoice was successfully cancelled"
_logger.info("Invoice successfully cancelled")
else:
self.data['status'] += "\n The invoice could not be cancelled."
_logger.error("Failed to cancel invoice, received response: %s", response)
class TremolG03Controller(http.Controller):
@http.route('/hw_proxy/l10n_ke_cu_send', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST'])
def l10n_ke_cu_send(self, messages, company_vat):
""" Posts the messages sent to this endpoint to the fiscal device connected to the server
:param messages: The messages (consisting of <CMD> and <DATA>) to
send to the fiscal device.
:returns: Dictionary containing a list of the responses from
fiscal device and status of the fiscal device.
"""
device = TremolG03Driver.get_default_device()
if device:
# First run the command to get the fiscal device numbers
device_numbers = device.send([b'\x60'])
# If the vat doesn't match, abort
if device_numbers['status'] != 'ok':
return device_numbers
serial_number, device_vat, _dummy = device_numbers['replies'][0].split(';')
if device_vat != company_vat:
return json.dumps({'status': 'The company vat number does not match that of the device'})
messages = json.loads(messages)
device.message_number = 0
resp = json.dumps({**device.send([msg.encode('cp1251') for msg in messages]), 'serial_number': serial_number})
return resp
else:
return json.dumps({'status': 'The fiscal device is not connected to the proxy server'})

View file

@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode
from cups import IPPError, IPP_PRINTER_IDLE, IPP_PRINTER_PROCESSING, IPP_PRINTER_STOPPED
import dbus
import io
import logging
import netifaces as ni
import os
from PIL import Image, ImageOps
import re
import subprocess
import tempfile
from uuid import getnode as get_mac
from odoo import http
from odoo.addons.hw_drivers.connection_manager import connection_manager
from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
from odoo.addons.hw_drivers.driver import Driver
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.iot_handlers.interfaces.PrinterInterface_L import PPDs, conn, cups_lock
from odoo.addons.hw_drivers.main import iot_devices
from odoo.addons.hw_drivers.tools import helpers
_logger = logging.getLogger(__name__)
RECEIPT_PRINTER_COMMANDS = {
'star': {
'center': b'\x1b\x1d\x61\x01', # ESC GS a n
'cut': b'\x1b\x64\x02', # ESC d n
'title': b'\x1b\x69\x01\x01%s\x1b\x69\x00\x00', # ESC i n1 n2
'drawers': [b'\x07', b'\x1a'] # BEL & SUB
},
'escpos': {
'center': b'\x1b\x61\x01', # ESC a n
'cut': b'\x1d\x56\x41\n', # GS V m
'title': b'\x1b\x21\x30%s\x1b\x21\x00', # ESC ! n
'drawers': [b'\x1b\x3d\x01', b'\x1b\x70\x00\x19\x19', b'\x1b\x70\x01\x19\x19'] # ESC = n then ESC p m t1 t2
}
}
def cups_notification_handler(message, uri, device_identifier, state, reason, accepting_jobs):
if device_identifier in iot_devices:
reason = reason if reason != 'none' else None
state_value = {
IPP_PRINTER_IDLE: 'connected',
IPP_PRINTER_PROCESSING: 'processing',
IPP_PRINTER_STOPPED: 'stopped'
}
iot_devices[device_identifier].update_status(state_value[state], message, reason)
# Create a Cups subscription if it doesn't exist yet
try:
conn.getSubscriptions('/printers/')
except IPPError:
conn.createSubscription(
uri='/printers/',
recipient_uri='dbus://',
events=['printer-state-changed']
)
# Listen for notifications from Cups
bus = dbus.SystemBus()
bus.add_signal_receiver(cups_notification_handler, signal_name="PrinterStateChanged", dbus_interface="org.cups.cupsd.Notifier")
class PrinterDriver(Driver):
connection_type = 'printer'
def __init__(self, identifier, device):
super(PrinterDriver, self).__init__(identifier, device)
self.device_type = 'printer'
self.device_connection = device['device-class'].lower()
self.device_name = device['device-make-and-model']
self.state = {
'status': 'connecting',
'message': 'Connecting to printer',
'reason': None,
}
self.send_status()
self._actions.update({
'cashbox': self.open_cashbox,
'print_receipt': self.print_receipt,
'': self._action_default,
})
self.receipt_protocol = 'star' if 'STR_T' in device['device-id'] else 'escpos'
if 'direct' in self.device_connection and any(cmd in device['device-id'] for cmd in ['CMD:STAR;', 'CMD:ESC/POS;']):
self.print_status()
@classmethod
def supported(cls, device):
if device.get('supported', False):
return True
protocol = ['dnssd', 'lpd', 'socket']
if any(x in device['url'] for x in protocol) and device['device-make-and-model'] != 'Unknown' or 'direct' in device['device-class']:
model = cls.get_device_model(device)
ppdFile = ''
for ppd in PPDs:
if model and model in PPDs[ppd]['ppd-product']:
ppdFile = ppd
break
with cups_lock:
if ppdFile:
conn.addPrinter(name=device['identifier'], ppdname=ppdFile, device=device['url'])
else:
conn.addPrinter(name=device['identifier'], device=device['url'])
conn.setPrinterInfo(device['identifier'], device['device-make-and-model'])
conn.enablePrinter(device['identifier'])
conn.acceptJobs(device['identifier'])
conn.setPrinterUsersAllowed(device['identifier'], ['all'])
conn.addPrinterOptionDefault(device['identifier'], "usb-no-reattach", "true")
conn.addPrinterOptionDefault(device['identifier'], "usb-unidir", "true")
return True
return False
@classmethod
def get_device_model(cls, device):
device_model = ""
if device.get('device-id'):
for device_id in [device_lo for device_lo in device['device-id'].split(';')]:
if any(x in device_id for x in ['MDL', 'MODEL']):
device_model = device_id.split(':')[1]
break
elif device.get('device-make-and-model'):
device_model = device['device-make-and-model']
return re.sub(r"[\(].*?[\)]", "", device_model).strip()
@classmethod
def get_status(cls):
status = 'connected' if any(iot_devices[d].device_type == "printer" and iot_devices[d].device_connection == 'direct' for d in iot_devices) else 'disconnected'
return {'status': status, 'messages': ''}
def disconnect(self):
self.update_status('disconnected', 'Printer was disconnected')
super(PrinterDriver, self).disconnect()
def update_status(self, status, message, reason=None):
"""Updates the state of the current printer.
Args:
status (str): The new value of the status
message (str): A comprehensive message describing the status
reason (str): The reason fo the current status
"""
if self.state['status'] != status or self.state['reason'] != reason:
self.state = {
'status': status,
'message': message,
'reason': reason,
}
self.send_status()
def send_status(self):
""" Sends the current status of the printer to the connected Odoo instance.
"""
self.data = {
'value': '',
'state': self.state,
}
event_manager.device_changed(self)
def print_raw(self, data):
process = subprocess.Popen(["lp", "-d", self.device_identifier], stdin=subprocess.PIPE)
process.communicate(data)
if process.returncode != 0:
# The stderr isn't meaningful so we don't log it ('No such file or directory')
_logger.error('Printing failed: printer with the identifier "%s" could not be found',
self.device_identifier)
def print_receipt(self, data):
receipt = b64decode(data['receipt'])
im = Image.open(io.BytesIO(receipt))
# Convert to greyscale then to black and white
im = im.convert("L")
im = ImageOps.invert(im)
im = im.convert("1")
print_command = getattr(self, 'format_%s' % self.receipt_protocol)(im)
self.print_raw(print_command)
def format_star(self, im):
width = int((im.width + 7) / 8)
raster_init = b'\x1b\x2a\x72\x41'
raster_page_length = b'\x1b\x2a\x72\x50\x30\x00'
raster_send = b'\x62'
raster_close = b'\x1b\x2a\x72\x42'
raster_data = b''
dots = im.tobytes()
while len(dots):
raster_data += raster_send + width.to_bytes(2, 'little') + dots[:width]
dots = dots[width:]
return raster_init + raster_page_length + raster_data + raster_close
def format_escpos_bit_image_raster(self, im):
""" prints with the `GS v 0`-command """
width = int((im.width + 7) / 8)
raster_send = b'\x1d\x76\x30\x00'
max_slice_height = 255
raster_data = b''
dots = im.tobytes()
while len(dots):
im_slice = dots[:width*max_slice_height]
slice_height = int(len(im_slice) / width)
raster_data += raster_send + width.to_bytes(2, 'little') + slice_height.to_bytes(2, 'little') + im_slice
dots = dots[width*max_slice_height:]
return raster_data
def extract_columns_from_picture(self, im, line_height):
# Code inspired from python esc pos library:
# https://github.com/python-escpos/python-escpos/blob/4a0f5855ef118a2009b843a3a106874701d8eddf/src/escpos/image.py#L73-L89
width_pixels, height_pixels = im.size
for left in range(0, width_pixels, line_height):
box = (left, 0, left + line_height, height_pixels)
im_chunk = im.transform(
(line_height, height_pixels),
Image.EXTENT,
box
)
yield im_chunk.tobytes()
def format_escpos_bit_image_column(self, im, high_density_vertical=True,
high_density_horizontal=True,
size_scale=100):
""" prints with the `ESC *`-command
reference: https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=88
:param im: PIL image to print
:param high_density_vertical: print in high density in vertical direction
:param high_density_horizontal: print in high density in horizontal direction
:param size_scale: picture scale in percentage,
e.g: 50 -> half the size (horizontally and vertically)
"""
size_scale_ratio = size_scale / 100
size_scale_width = int(im.width * size_scale_ratio)
size_scale_height = int(im.height * size_scale_ratio)
im = im.resize((size_scale_width, size_scale_height))
# escpos ESC * command print column per column
# (instead of usual row by row).
# So we transpose the picture to ease the calculations
im = im.transpose(Image.ROTATE_270).transpose(Image.FLIP_LEFT_RIGHT)
# Most of the code here is inspired from python escpos library
# https://github.com/python-escpos/python-escpos/blob/4a0f5855ef118a2009b843a3a106874701d8eddf/src/escpos/escpos.py#L237C9-L251
ESC = b'\x1b'
density_byte = (1 if high_density_horizontal else 0) + \
(32 if high_density_vertical else 0)
nL = im.height & 0xFF
nH = (im.height >> 8) & 0xFF
HEADER = ESC + b'*' + bytes([density_byte, nL, nH])
raster_data = ESC + b'3\x10' # Adjust line-feed size
line_height = 24 if high_density_vertical else 8
for column in self.extract_columns_from_picture(im, line_height):
raster_data += HEADER + column + b'\n'
raster_data += ESC + b'2' # Reset line-feed size
return raster_data
def format_escpos(self, im):
# Epson support different command to print pictures.
# We use by default "GS v 0", but it is incompatible with certain
# printer models (like TM-U2x0)
# As we are pretty limited in the information that we have, we will
# use the printer name to parse some configuration value
# Printer name examples:
# EpsonTMM30
# -> Print using raster mode
# TM-U220__IMC_LDV_LDH_SCALE70__
# -> Print using column bit image mode (without vertical and
# horizontal density and a scale of 70%)
# Default image printing mode
image_mode = 'raster'
options_str = self.device_name.split('__')
option_str = ""
if len(options_str) > 2:
option_str = options_str[1].upper()
if option_str.startswith('IMC'):
image_mode = 'column'
if image_mode == 'column':
# Default printing mode parameters
high_density_vertical = True
high_density_horizontal = True
scale = 100
# Parse the printer name to get the needed parameters
# The separator need to not be filtered by `get_identifier`
options = option_str.split('_')
for option in options:
if option == 'LDV':
high_density_vertical = False
elif option == 'LDH':
high_density_horizontal = False
elif option.startswith('SCALE'):
scale_value_str = re.search(r'\d+$', option)
if scale_value_str is not None:
scale = int(scale_value_str.group())
else:
raise ValueError(
"Missing printer SCALE parameter integer "
"value in option: " + option)
res = self.format_escpos_bit_image_column(im,
high_density_vertical,
high_density_horizontal,
scale)
else:
res = self.format_escpos_bit_image_raster(im)
return res + RECEIPT_PRINTER_COMMANDS['escpos']['cut']
def print_status(self):
"""Prints the status ticket of the IoTBox on the current printer."""
wlan = ''
ip = ''
mac = ''
homepage = ''
pairing_code = ''
ssid = helpers.get_ssid()
wlan = '\nWireless network:\n%s\n\n' % ssid
interfaces = ni.interfaces()
ips = []
for iface_id in interfaces:
iface_obj = ni.ifaddresses(iface_id)
ifconfigs = iface_obj.get(ni.AF_INET, [])
for conf in ifconfigs:
if conf.get('addr') and conf.get('addr'):
ips.append(conf.get('addr'))
if len(ips) == 0:
ip = '\nERROR: Could not connect to LAN\n\nPlease check that the IoTBox is correc-\ntly connected with a network cable,\n that the LAN is setup with DHCP, and\nthat network addresses are available'
elif len(ips) == 1:
ip = '\nIP Address:\n%s\n' % ips[0]
else:
ip = '\nIP Addresses:\n%s\n' % '\n'.join(ips)
if len(ips) >= 1:
ips_filtered = [i for i in ips if i != '127.0.0.1']
main_ips = ips_filtered and ips_filtered[0] or '127.0.0.1'
mac = '\nMAC Address:\n%s\n' % helpers.get_mac_address()
homepage = '\nHomepage:\nhttp://%s:8069\n\n' % main_ips
code = connection_manager.pairing_code
if code:
pairing_code = '\nPairing Code:\n%s\n' % code
commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
title = commands['title'] % b'IoTBox Status'
self.print_raw(commands['center'] + title + b'\n' + wlan.encode() + mac.encode() + ip.encode() + homepage.encode() + pairing_code.encode() + commands['cut'])
def open_cashbox(self, data):
"""Sends a signal to the current printer to open the connected cashbox."""
commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
for drawer in commands['drawers']:
self.print_raw(drawer)
def _action_default(self, data):
self.print_raw(b64decode(data['document']))
class PrinterController(http.Controller):
@http.route('/hw_proxy/default_printer_action', type='json', auth='none', cors='*')
def default_printer_action(self, data):
printer = next((d for d in iot_devices if iot_devices[d].device_type == 'printer' and iot_devices[d].device_connection == 'direct'), None)
if printer:
iot_devices[printer].action(data)
return True
return False
proxy_drivers['printer'] = PrinterDriver

View file

@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from PIL import Image, ImageOps
import logging
from base64 import b64decode
import io
import win32print
import ghostscript
from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
from odoo.addons.hw_drivers.driver import Driver
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.main import iot_devices
from odoo.addons.hw_drivers.tools import helpers
from odoo.tools.mimetypes import guess_mimetype
_logger = logging.getLogger(__name__)
RECEIPT_PRINTER_COMMANDS = {
'star': {
'center': b'\x1b\x1d\x61\x01', # ESC GS a n
'cut': b'\x1b\x64\x02', # ESC d n
'title': b'\x1b\x69\x01\x01%s\x1b\x69\x00\x00', # ESC i n1 n2
'drawers': [b'\x07', b'\x1a'] # BEL & SUB
},
'escpos': {
'center': b'\x1b\x61\x01', # ESC a n
'cut': b'\x1d\x56\x41\n', # GS V m
'title': b'\x1b\x21\x30%s\x1b\x21\x00', # ESC ! n
'drawers': [b'\x1b\x3d\x01', b'\x1b\x70\x00\x19\x19', b'\x1b\x70\x01\x19\x19'] # ESC = n then ESC p m t1 t2
}
}
class PrinterDriver(Driver):
connection_type = 'printer'
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'printer'
self.device_connection = 'network'
self.device_name = device.get('identifier')
self.printer_handle = device.get('printer_handle')
self.state = {
'status': 'connecting',
'message': 'Connecting to printer',
'reason': None,
}
self.send_status()
self._actions.update({
'cashbox': self.open_cashbox,
'print_receipt': self.print_receipt,
'': self._action_default,
})
self.receipt_protocol = 'escpos'
@classmethod
def supported(cls, device):
return True
@classmethod
def get_status(cls):
status = 'connected' if any(iot_devices[d].device_type == "printer" and iot_devices[d].device_connection == 'direct' for d in iot_devices) else 'disconnected'
return {'status': status, 'messages': ''}
def disconnect(self):
self.update_status('disconnected', 'Printer was disconnected')
super(PrinterDriver, self).disconnect()
def update_status(self, status, message, reason=None):
"""Updates the state of the current printer.
Args:
status (str): The new value of the status
message (str): A comprehensive message describing the status
reason (str): The reason fo the current status
"""
if self.state['status'] != status or self.state['reason'] != reason:
self.state = {
'status': status,
'message': message,
'reason': reason,
}
self.send_status()
def send_status(self):
""" Sends the current status of the printer to the connected Odoo instance.
"""
self.data = {
'value': '',
'state': self.state,
}
event_manager.device_changed(self)
def print_raw(self, data):
win32print.StartDocPrinter(self.printer_handle, 1, ('', None, "RAW"))
win32print.StartPagePrinter(self.printer_handle)
win32print.WritePrinter(self.printer_handle, data)
win32print.EndPagePrinter(self.printer_handle)
win32print.EndDocPrinter(self.printer_handle)
def print_report(self, data):
helpers.write_file('document.pdf', data, 'wb')
file_name = helpers.path_file('document.pdf')
printer = self.device_name
args = [
"-dPrinted", "-dBATCH", "-dNOPAUSE", "-dNOPROMPT"
"-q",
"-sDEVICE#mswinpr2",
f'-sOutputFile#%printer%{printer}',
f'{file_name}'
]
_logger.debug("Printing report with ghostscript using %s", args)
stderr_buf = io.BytesIO()
stdout_buf = io.BytesIO()
stdout_log_level = logging.DEBUG
try:
ghostscript.Ghostscript(*args, stdout=stdout_buf, stderr=stderr_buf)
except Exception:
_logger.exception("Error while printing report, ghostscript args: %s, error buffer: %s", args, stderr_buf.getvalue())
stdout_log_level = logging.ERROR # some stdout value might contains relevant error information
raise
finally:
_logger.log(stdout_log_level, "Ghostscript stdout: %s", stdout_buf.getvalue())
def print_receipt(self, data):
receipt = b64decode(data['receipt'])
im = Image.open(io.BytesIO(receipt))
# Convert to greyscale then to black and white
im = im.convert("L")
im = ImageOps.invert(im)
im = im.convert("1")
print_command = getattr(self, 'format_%s' % self.receipt_protocol)(im)
self.print_raw(print_command)
def format_escpos(self, im):
width = int((im.width + 7) / 8)
raster_send = b'\x1d\x76\x30\x00'
max_slice_height = 255
raster_data = b''
dots = im.tobytes()
while dots:
im_slice = dots[:width*max_slice_height]
slice_height = int(len(im_slice) / width)
raster_data += raster_send + width.to_bytes(2, 'little') + slice_height.to_bytes(2, 'little') + im_slice
dots = dots[width*max_slice_height:]
return raster_data + RECEIPT_PRINTER_COMMANDS['escpos']['cut']
def open_cashbox(self, data):
"""Sends a signal to the current printer to open the connected cashbox."""
commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
for drawer in commands['drawers']:
self.print_raw(drawer)
def _action_default(self, data):
document = b64decode(data['document'])
mimetype = guess_mimetype(document)
if mimetype == 'application/pdf':
self.print_report(document)
else:
self.print_raw(document)
proxy_drivers['printer'] = PrinterDriver

View file

@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import namedtuple
from contextlib import contextmanager
import logging
import serial
from threading import Lock
import time
import traceback
from odoo import _
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.driver import Driver
_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, identifier, device):
""" Attributes initialization method for `SerialDriver`.
:param device: path to the device
:type device: str
"""
super(SerialDriver, self).__init__(identifier, device)
self._actions.update({
'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
"""
with self._device_lock:
try:
self._actions[data['action']](data)
time.sleep(self._protocol.commandDelay)
except Exception:
msg = _(f'An error occurred while performing action "{data}" on "{self.device_name}"')
_logger.exception(msg)
self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()}
self._push_status()
self._status = {'status': self.STATUS_CONNECTED, 'message_title': '', 'message_body': ''}
self.data['status'] = self._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.device_identifier, 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.device_identifier, self._protocol) as connection:
self._connection = connection
self._status['status'] = self.STATUS_CONNECTED
self._push_status()
while not self._stopped.is_set():
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()

View file

@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import namedtuple
import logging
import re
import serial
import threading
import time
from odoo import http
from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
from odoo.addons.hw_drivers.event_manager import event_manager
from odoo.addons.hw_drivers.iot_handlers.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=br"\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."""
last_sent_value = None
def __init__(self, identifier, device):
super(ScaleDriver, self).__init__(identifier, device)
self.device_type = 'scale'
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
proxy_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, data):
"""Starts asking for the scale value."""
self._is_reading = True
def _stop_reading_action(self, data):
"""Stops asking for the scale value."""
self._is_reading = False
def _clear_tare_action(self, data):
"""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, data):
"""Reads the scale current weight value and pushes it to the frontend."""
self._read_weight()
self.last_sent_value = self.data['value']
event_manager.device_changed(self)
def _set_zero_action(self, data):
"""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, data):
"""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)
if match:
self.data = {
'value': float(match.group(1)),
'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'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR:
self.last_sent_value = self.data['value']
event_manager.device_changed(self)
class Toledo8217Driver(ScaleDriver):
"""Driver for the Toldedo 8217 serial scale."""
_protocol = Toledo8217Protocol
def __init__(self, identifier, device):
super(Toledo8217Driver, self).__init__(identifier, device)
self.device_manufacturer = 'Toledo'
@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['identifier'], 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
priority = 0 # Test the supported method of this driver last, after all other serial drivers
def __init__(self, identifier, device):
super(AdamEquipmentDriver, self).__init__(identifier, device)
self._is_reading = False
self._last_weight_time = 0
self.device_manufacturer = 'Adam'
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'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR:
self.last_sent_value = self.data['value']
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['identifier'], 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

View file

@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from re import sub, finditer
import subprocess
import RPi.GPIO as GPIO
import logging
from odoo.addons.hw_drivers.interface import Interface
_logger = logging.getLogger(__name__)
try:
from vcgencmd import Vcgencmd
except ImportError:
Vcgencmd = None
_logger.warning('Could not import library vcgencmd')
class DisplayInterface(Interface):
_loop_delay = 0
connection_type = 'display'
def get_devices(self):
# If no display connected, create "fake" device to be accessed from another computer
display_devices = {
'distant_display' : {
'name': "Distant Display",
},
}
if Vcgencmd:
return self.get_devices_vcgencmd() or display_devices
else:
return self.get_devices_tvservice() or display_devices
def get_devices_tvservice(self):
display_devices = {}
displays = subprocess.check_output(['tvservice', '-l']).decode()
x_screen = 0
for match in finditer(r'Display Number (\d), type HDMI (\d)', displays):
display_id, hdmi_id = match.groups()
tvservice_output = subprocess.check_output(['tvservice', '-nv', display_id]).decode().strip()
if tvservice_output:
display_name = tvservice_output.split('=')[1]
display_identifier = sub('[^a-zA-Z0-9 ]+', '', display_name).replace(' ', '_') + "_" + str(hdmi_id)
iot_device = {
'identifier': display_identifier,
'name': display_name,
'x_screen': str(x_screen),
}
display_devices[display_identifier] = iot_device
x_screen += 1
return display_devices
def get_devices_vcgencmd(self):
"""
With the new IoT build 23_11 which uses Raspi OS Bookworm,
tvservice is no longer usable.
vcgencmd returns the display power state as on or off of the display whose ID is passed as the parameter.
The display ID for the preceding three methods are determined by the following table.
Display ID
Main LCD 0
Secondary LCD 1
HDMI 0 2
Composite 3
HDMI 1 7
"""
display_devices = {}
x_screen = 0
hdmi_port = {'hdmi_0' : 2} # HDMI 0
rpi_type = GPIO.RPI_INFO.get('TYPE')
# Check if it is a RPI 3B+ beacause he response on for booth hdmi port
if 'Pi 4' in rpi_type:
hdmi_port.update({'hdmi_1': 7}) # HDMI 1
try:
for hdmi in hdmi_port:
power_state_hdmi = Vcgencmd().display_power_state(hdmi_port.get(hdmi))
if power_state_hdmi == 'on':
iot_device = {
'identifier': hdmi,
'name': 'Display hdmi ' + str(x_screen),
'x_screen': str(x_screen),
}
display_devices[hdmi] = iot_device
x_screen += 1
except subprocess.CalledProcessError:
_logger.warning('Vcgencmd "display_power_state" method call failed')
return display_devices

View file

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from cups import Connection as cups_connection
from re import sub
from threading import Lock
from odoo.addons.hw_drivers.interface import Interface
conn = cups_connection()
PPDs = conn.getPPDs()
cups_lock = Lock() # We can only make one call to Cups at a time
class PrinterInterface(Interface):
_loop_delay = 120
connection_type = 'printer'
printer_devices = {}
def get_devices(self):
discovered_devices = {}
with cups_lock:
printers = conn.getPrinters()
devices = conn.getDevices()
for printer_name, printer in printers.items():
path = printer.get('device-uri', False)
if printer_name != self.get_identifier(path):
printer.update({'supported': True}) # these printers are automatically supported
device_class = 'network'
if 'usb' in printer.get('device-uri'):
device_class = 'direct'
printer.update({'device-class': device_class})
printer.update({'device-make-and-model': printer_name}) # give name setted in Cups
printer.update({'device-id': ''})
devices.update({printer_name: printer})
for path, device in devices.items():
identifier = self.get_identifier(path)
device.update({'identifier': identifier})
device.update({'url': path})
device.update({'disconnect_counter': 0})
discovered_devices.update({identifier: device})
self.printer_devices.update(discovered_devices)
# Deal with devices which are on the list but were not found during this call of "get_devices"
# If they aren't detected 3 times consecutively, remove them from the list of available devices
for device in list(self.printer_devices):
if not discovered_devices.get(device):
disconnect_counter = self.printer_devices.get(device).get('disconnect_counter')
if disconnect_counter >= 2:
self.printer_devices.pop(device, None)
else:
self.printer_devices[device].update({'disconnect_counter': disconnect_counter + 1})
return dict(self.printer_devices)
def get_identifier(self, path):
"""
Necessary because the path is not always a valid Cups identifier,
as it may contain characters typically found in URLs or paths.
- Removes characters: ':', '/', '.', '\', and space.
- Removes the exact strings: "uuid=" and "serial=".
Example 1:
Input: "ipp://printers/printer1:1234/abcd"
Output: "ippprintersprinter11234abcd"
Example 2:
Input: "uuid=1234-5678-90ab-cdef"
Output: "1234-5678-90ab-cdef
"""
return sub(r'[:\/\.\\ ]|(uuid=)|(serial=)', '', path)

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import win32print
from odoo.addons.hw_drivers.interface import Interface
class PrinterInterface(Interface):
_loop_delay = 30
connection_type = 'printer'
def get_devices(self):
printer_devices = {}
printers = win32print.EnumPrinters(win32print.PRINTER_ENUM_LOCAL)
for printer in printers:
identifier = printer[2]
handle_printer = win32print.OpenPrinter(identifier)
win32print.GetPrinter(handle_printer, 2)
printer_devices[identifier] = {
'identifier': identifier,
'printer_handle': handle_printer,
}
return printer_devices

View file

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import serial.tools.list_ports
from odoo.addons.hw_drivers.interface import Interface
class SerialInterface(Interface):
connection_type = 'serial'
def get_devices(self):
serial_devices = {}
for port in serial.tools.list_ports.comports():
serial_devices[port.device] = {
'identifier': port.device
}
return serial_devices

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from usb import core
from odoo.addons.hw_drivers.interface import Interface
class USBInterface(Interface):
connection_type = 'usb'
def get_devices(self):
"""
USB devices are identified by a combination of their `idVendor` and
`idProduct`. We can't be sure this combination in unique per equipment.
To still allow connecting multiple similar equipments, we complete the
identifier by a counter. The drawbacks are we can't be sure the equipments
will get the same identifiers after a reboot or a disconnect/reconnect.
"""
usb_devices = {}
devs = core.find(find_all=True)
cpt = 2
for dev in devs:
identifier = "usb_%04x:%04x" % (dev.idVendor, dev.idProduct)
if identifier in usb_devices:
identifier += '_%s' % cpt
cpt += 1
usb_devices[identifier] = dev
return usb_devices

View file

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from traceback import format_exc
import json
import platform
import logging
import socket
import subprocess
from threading import Thread
import time
import urllib3
from odoo.addons.hw_drivers.tools import helpers
_logger = logging.getLogger(__name__)
try:
import schedule
except ImportError:
schedule = None
_logger.warning('Could not import library schedule')
try:
from dbus.mainloop.glib import DBusGMainLoop
except ImportError:
DBusGMainLoop = None
_logger.error('Could not import library dbus')
drivers = []
interfaces = {}
iot_devices = {}
class Manager(Thread):
def send_alldevices(self):
"""
This method send IoT Box and devices informations to Odoo database
"""
server = helpers.get_odoo_server_url()
if server:
subject = helpers.read_file_first_line('odoo-subject.conf')
if subject:
domain = helpers.get_ip().replace('.', '-') + subject.strip('*')
else:
domain = helpers.get_ip()
iot_box = {
'name': socket.gethostname(),
'identifier': helpers.get_mac_address(),
'ip': domain,
'token': helpers.get_token(),
'version': helpers.get_version(detailed_version=True),
}
devices_list = {}
for device in iot_devices:
identifier = iot_devices[device].device_identifier
devices_list[identifier] = {
'name': iot_devices[device].device_name,
'type': iot_devices[device].device_type,
'manufacturer': iot_devices[device].device_manufacturer,
'connection': iot_devices[device].device_connection,
}
data = {'params': {'iot_box': iot_box, 'devices': devices_list,}}
# disable certifiacte verification
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE')
try:
http.request(
'POST',
server + "/iot/setup",
body=json.dumps(data).encode('utf8'),
headers={
'Content-type': 'application/json',
'Accept': 'text/plain',
},
)
except Exception:
_logger.exception('Could not reach configured server to send all IoT devices')
else:
_logger.warning('Odoo server not set')
def run(self):
"""
Thread that will load interfaces and drivers and contact the odoo server with the updates
"""
helpers.start_nginx_server()
if platform.system() == 'Linux' and not helpers.get_odoo_server_url():
self.migrate_config()
_logger.info("IoT Box Image version: %s", helpers.get_version(detailed_version=True))
if platform.system() == 'Linux' and helpers.get_odoo_server_url():
helpers.check_git_branch()
helpers.generate_password()
is_certificate_ok, certificate_details = helpers.get_certificate_status()
if not is_certificate_ok:
_logger.warning("An error happened when trying to get the HTTPS certificate: %s",
certificate_details)
# We first add the IoT Box to the connected DB because IoT handlers cannot be downloaded if
# the identifier of the Box is not found in the DB. So add the Box to the DB.
self.send_alldevices()
helpers.download_iot_handlers()
helpers.load_iot_handlers()
# Start the interfaces
for interface in interfaces.values():
try:
i = interface()
i.daemon = True
i.start()
except Exception:
_logger.exception("Interface %s could not be started", str(interface))
# Set scheduled actions
schedule and schedule.every().day.at("00:00").do(helpers.get_certificate_status)
# Check every 3 secondes if the list of connected devices has changed and send the updated
# list to the connected DB.
self.previous_iot_devices = []
while 1:
try:
if iot_devices != self.previous_iot_devices:
self.previous_iot_devices = iot_devices.copy()
self.send_alldevices()
time.sleep(3)
schedule and schedule.run_pending()
except Exception:
# No matter what goes wrong, the Manager loop needs to keep running
_logger.exception("Manager loop unexpected error")
def migrate_config(self):
"""
This is a workaround for new IoT box images (>=24.10) not working correctly after
checking out a v16 database. It transforms the new odoo.conf settings into their
equivalent config files.
"""
with helpers.writable():
subprocess.run(
['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/migrate_config.sh'], check=True
)
# Must be started from main thread
if DBusGMainLoop:
DBusGMainLoop(set_as_default=True)
manager = Manager()
manager.daemon = True
manager.start()

View file

@ -0,0 +1,164 @@
import logging
import queue
import requests
import threading
import time
import urllib3.exceptions
from odoo.addons.hw_drivers.tools import helpers
from odoo.netsvc import DBFormatter
from odoo.tools import config
_logger = logging.getLogger(__name__)
IOT_LOG_TO_SERVER_CONFIG_NAME = 'iot_log_to_server' # config name in odoo.conf
class AsyncHTTPHandler(logging.Handler):
"""
Custom logging handler which send IoT logs using asynchronous requests.
To avoid spamming the server, we send logs by batch each X seconds
"""
_MAX_QUEUE_SIZE = 1000
"""Maximum queue size. If a log record is received but the queue if full it will be discarded"""
_MAX_BATCH_SIZE = 50
"""Maximum number of sent logs batched at once. Used to avoid too heavy request. Log records still in the queue will
be handle in future flushes"""
_FLUSH_INTERVAL = 0.5
"""How much seconds it will sleep before checking for new logs to send"""
_REQUEST_TIMEOUT = 0.5
"""Amount of seconds to wait per log to send before timeout"""
_DELAY_BEFORE_NO_SERVER_LOG = 5 * 60 # 5 minutes
"""Minimum delay in seconds before we log a server disconnection.
Used in order to avoid the IoT log file to have a log recorded each _FLUSH_INTERVAL (as this value is very small)"""
def __init__(self, odoo_server_url, active):
"""
:param odoo_server_url: Odoo Server URL
"""
super().__init__()
self._odoo_server_url = odoo_server_url
self._log_queue = queue.Queue(self._MAX_QUEUE_SIZE)
self._flush_thread = None
self._active = None
self._next_disconnection_time = None
self.toggle_active(active)
def toggle_active(self, is_active):
"""
Switch it on or off the handler (depending on the IoT setting) without the need to close/reset it
"""
self._active = is_active
if self._active and self._odoo_server_url:
# Start the thread to periodically flush logs
self._flush_thread = threading.Thread(target=self._periodic_flush, name="ThreadServerLogSender", daemon=True)
self._flush_thread.start()
else:
self._flush_thread and self._flush_thread.join() # let a last flush
def _periodic_flush(self):
odoo_session = requests.Session()
while self._odoo_server_url and self._active: # allow to exit the loop on thread.join
time.sleep(self._FLUSH_INTERVAL)
self._flush_logs(odoo_session)
def _flush_logs(self, odoo_session):
def convert_to_byte(s):
return bytes(s, encoding="utf-8") + b'<log/>\n'
def convert_server_line(log_level, line_formatted):
return convert_to_byte(f"{log_level},{line_formatted}")
def empty_queue():
yield convert_to_byte(f"mac {helpers.get_mac_address()}")
for _ in range(self._MAX_BATCH_SIZE):
# Use a limit to avoid having too heavy requests & infinite loop of the queue receiving new entries
try:
log_record = self._log_queue.get_nowait()
yield convert_server_line(log_record.levelno, self.format(log_record))
except queue.Empty:
break
# Report to the server if the queue is close from saturation
if queue_size >= .8 * self._MAX_QUEUE_SIZE:
log_message = "The IoT {} queue is saturating: {}/{} ({:.2f}%)".format(
self.__class__.__name__, queue_size, self._MAX_QUEUE_SIZE,
100 * queue_size / self._MAX_QUEUE_SIZE)
_logger.warning(log_message) # As we don't log our own logs, this will be part of the IoT logs
# In order to report this to the server (on the current batch) we will append it manually
yield convert_server_line(logging.WARNING, log_message)
queue_size = self._log_queue.qsize() # This is an approximate value
if not self._odoo_server_url or queue_size == 0:
return
try:
odoo_session.post(
self._odoo_server_url + '/iot/log',
data=empty_queue(),
timeout=self._REQUEST_TIMEOUT
).raise_for_status()
self._next_disconnection_time = None
except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError, urllib3.exceptions.NewConnectionError) as request_errors:
now = time.time()
if not self._next_disconnection_time or now >= self._next_disconnection_time:
_logger.info("Connection with the server to send the logs failed. It is likely down: %s", request_errors)
self._next_disconnection_time = now + self._DELAY_BEFORE_NO_SERVER_LOG
except Exception as _:
_logger.exception('Unexpected error happened while sending logs to server')
def emit(self, record):
# This is important that this method is as fast as possible.
# The log calls will be waiting for this function to finish
if not self._active:
return
try:
self._log_queue.put_nowait(record)
except queue.Full:
pass
def close(self):
self.toggle_active(False)
super().close()
def close_server_log_sender_handler():
_server_log_sender_handler.close()
def get_odoo_config_log_to_server_option():
return config.get(IOT_LOG_TO_SERVER_CONFIG_NAME, True) # Enabled by default
def check_and_update_odoo_config_log_to_server_option(new_state):
"""
:return: wherever the config file need to be updated or not
"""
if get_odoo_config_log_to_server_option() != new_state:
config[IOT_LOG_TO_SERVER_CONFIG_NAME] = new_state
_server_log_sender_handler.toggle_active(new_state)
return True
return False
def _server_log_sender_handler_filter(log_record):
def _filter_my_logs():
"""Filter out our own logs (to avoid infinite loop)"""
return log_record.name == __name__
def _filter_frequent_irrelevant_calls():
"""Filter out this frequent irrelevant HTTP calls, to avoid spamming the server with useless logs"""
return log_record.name == 'werkzeug' and log_record.args and len(log_record.args) > 0 and log_record.args[0].startswith('GET /hw_proxy/hello ')
return not (_filter_my_logs() or _filter_frequent_irrelevant_calls())
# The server URL is set once at initlialisation as the IoT will always restart if the URL is changed
# The only other possible case is when the server URL value is "Cleared",
# in this case we force close the log handler (as it does not make sense anymore)
_server_log_sender_handler = AsyncHTTPHandler(helpers.get_odoo_server_url(), get_odoo_config_log_to_server_option())
_server_log_sender_handler.setFormatter(DBFormatter('%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s %(perf_info)s'))
_server_log_sender_handler.addFilter(_server_log_sender_handler_filter)
# Set it in the 'root' logger, on which every logger (including odoo) is a child
logging.getLogger().addHandler(_server_log_sender_handler)

View file

@ -0,0 +1,63 @@
/* global display_identifier */
$(function() {
"use strict";
// mergedHead will be turned to true the first time we receive something from a new host
// It allows to transform the <head> only once
var mergedHead = false;
var current_client_url = "";
function longpolling() {
$.ajax({
type: 'POST',
url: window.location.origin + '/point_of_sale/get_serialized_order/' + display_identifier,
dataType: 'json',
beforeSend: function(xhr){xhr.setRequestHeader('Content-Type', 'application/json');},
data: JSON.stringify({jsonrpc: '2.0'}),
success: function(data) {
if (data.result.error) {
$('.error-message').text(data.result.error);
$('.error-message').removeClass('d-none');
setTimeout(longpolling, 5000);
return;
}
if (data.result.rendered_html) {
var trimmed = $.trim(data.result.rendered_html);
var $parsedHTML = $('<div>').html($.parseHTML(trimmed,true)); // WARNING: the true here will executes any script present in the string to parse
var new_client_url = $parsedHTML.find(".resources > base").attr('href');
if (!mergedHead || (current_client_url !== new_client_url)) {
mergedHead = true;
current_client_url = new_client_url;
$("head").children().not('.origin').remove();
$("head").append($parsedHTML.find(".resources").html());
}
$(".container-fluid").html($parsedHTML.find('.pos-customer_facing_display').html());
$(".container-fluid").attr('class', 'container-fluid').addClass($parsedHTML.find('.pos-customer_facing_display').attr('class'));
var d = $('.pos_orderlines_list');
d.scrollTop(d.prop("scrollHeight"));
// Here we execute the code coming from the pos, apparently $.parseHTML() executes scripts right away,
// Since we modify the dom afterwards, the script might not have any effect
/* eslint-disable no-undef */
if (typeof foreign_js !== 'undefined' && $.isFunction(foreign_js)) {
foreign_js();
}
/* eslint-enable no-undef */
}
longpolling();
},
error: function (jqXHR, status, err) {
setTimeout(longpolling, 5000);
},
timeout: 30000,
});
};
longpolling();
});

View file

@ -0,0 +1,501 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from enum import Enum
from importlib import util
import platform
import io
import json
import logging
import netifaces
from OpenSSL import crypto
import os
from pathlib import Path
import subprocess
import urllib3
import zipfile
from threading import Thread
import time
import contextlib
import requests
import secrets
from odoo import _, http, release, service
from odoo.tools.func import lazy_property
from odoo.tools.misc import file_path
from odoo.modules.module import get_resource_path
_logger = logging.getLogger(__name__)
try:
import crypt
except ImportError:
_logger.warning('Could not import library crypt')
#----------------------------------------------------------
# Helper
#----------------------------------------------------------
class CertificateStatus(Enum):
OK = 1
NEED_REFRESH = 2
ERROR = 3
class IoTRestart(Thread):
"""
Thread to restart odoo server in IoT Box when we must return a answer before
"""
def __init__(self, delay):
Thread.__init__(self)
self.delay = delay
def run(self):
time.sleep(self.delay)
service.server.restart()
if platform.system() == 'Windows':
writable = contextlib.nullcontext
elif platform.system() == 'Linux':
@contextlib.contextmanager
def writable():
subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"])
subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/"])
try:
yield
finally:
subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"])
subprocess.call(["sudo", "mount", "-o", "remount,ro", "/root_bypass_ramdisks/"])
subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"])
def access_point():
return get_ip() == '10.11.12.1'
def start_nginx_server():
if platform.system() == 'Windows':
path_nginx = get_path_nginx()
if path_nginx:
os.chdir(path_nginx)
_logger.info('Start Nginx server: %s\\nginx.exe', path_nginx)
os.popen('nginx.exe')
os.chdir('..\\server')
elif platform.system() == 'Linux':
subprocess.check_call(["sudo", "service", "nginx", "restart"])
def check_certificate():
"""
Check if the current certificate is up to date or not authenticated
:return CheckCertificateStatus
"""
server = get_odoo_server_url()
if not server:
return {"status": CertificateStatus.ERROR,
"error_code": "ERR_IOT_HTTPS_CHECK_NO_SERVER"}
if platform.system() == 'Windows':
path = Path(get_path_nginx()).joinpath('conf/nginx-cert.crt')
elif platform.system() == 'Linux':
path = Path('/etc/ssl/certs/nginx-cert.crt')
if not path.exists():
return {"status": CertificateStatus.NEED_REFRESH}
try:
with path.open('r') as f:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read())
except EnvironmentError:
_logger.exception("Unable to read certificate file")
return {"status": CertificateStatus.ERROR,
"error_code": "ERR_IOT_HTTPS_CHECK_CERT_READ_EXCEPTION"}
cert_end_date = datetime.datetime.strptime(cert.get_notAfter().decode('utf-8'), "%Y%m%d%H%M%SZ") - datetime.timedelta(days=10)
for key in cert.get_subject().get_components():
if key[0] == b'CN':
cn = key[1].decode('utf-8')
if cn == 'OdooTempIoTBoxCertificate' or datetime.datetime.now() > cert_end_date:
message = _('Your certificate %s must be updated') % (cn)
_logger.info(message)
return {"status": CertificateStatus.NEED_REFRESH}
else:
message = _('Your certificate %s is valid until %s') % (cn, cert_end_date)
_logger.info(message)
return {"status": CertificateStatus.OK, "message": message}
def check_git_branch():
"""
Check if the local branch is the same than the connected Odoo DB and
checkout to match it if needed.
"""
server = get_odoo_server_url()
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE')
try:
response = http.request('POST',
server + "/web/webclient/version_info",
body='{}',
headers={'Content-type': 'application/json'}
)
if response.status == 200:
git = ['git', '--work-tree=/home/pi/odoo/', '--git-dir=/home/pi/odoo/.git']
db_branch = json.loads(response.data)['result']['server_serie'].replace('~', '-')
if not subprocess.check_output(git + ['ls-remote', 'origin', db_branch]):
db_branch = 'master'
local_branch = subprocess.check_output(git + ['symbolic-ref', '-q', '--short', 'HEAD']).decode('utf-8').rstrip()
_logger.info("Current IoT Box local git branch: %s / Associated Odoo database's git branch: %s", local_branch, db_branch)
if db_branch != local_branch:
with writable():
subprocess.check_call(["rm", "-rf", "/home/pi/odoo/addons/hw_drivers/iot_handlers/drivers/*"])
subprocess.check_call(["rm", "-rf", "/home/pi/odoo/addons/hw_drivers/iot_handlers/interfaces/*"])
subprocess.check_call(git + ['branch', '-m', db_branch])
subprocess.check_call(git + ['remote', 'set-branches', 'origin', db_branch])
os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/posbox_update.sh')
except Exception:
_logger.exception('An error occurred while trying to update the code with git')
def check_image():
"""
Check if the current image of IoT Box is up to date
"""
url = 'https://nightly.odoo.com/master/iotbox/SHA1SUMS.txt'
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE')
response = http.request('GET', url)
checkFile = {}
valueActual = ''
for line in response.data.decode().split('\n'):
if line:
value, name = line.split(' ')
checkFile.update({value: name})
if name == 'iotbox-latest.zip':
valueLastest = value
elif name == get_img_name():
valueActual = value
if valueActual == valueLastest:
return False
version = checkFile.get(valueLastest, 'Error').replace('iotboxv', '').replace('.zip', '').split('_')
return {'major': version[0], 'minor': version[1]}
def save_conf_server(url, token, db_uuid, enterprise_code):
"""
Save config to connect IoT to the server
"""
write_file('odoo-remote-server.conf', url)
write_file('token', token)
write_file('odoo-db-uuid.conf', db_uuid or '')
write_file('odoo-enterprise-code.conf', enterprise_code or '')
def generate_password():
"""
Generate an unique code to secure raspberry pi
"""
alphabet = 'abcdefghijkmnpqrstuvwxyz23456789'
password = ''.join(secrets.choice(alphabet) for i in range(12))
try:
shadow_password = crypt.crypt(password, crypt.mksalt())
subprocess.run(('sudo', 'usermod', '-p', shadow_password, 'pi'), check=True)
with writable():
subprocess.run(('sudo', 'cp', '/etc/shadow', '/root_bypass_ramdisks/etc/shadow'), check=True)
return password
except subprocess.CalledProcessError as e:
_logger.exception("Failed to generate password: %s", e.output)
return 'Error: Check IoT log'
def get_certificate_status(is_first=True):
"""
Will get the HTTPS certificate details if present. Will load the certificate if missing.
:param is_first: Use to make sure that the recursion happens only once
:return: (bool, str)
"""
check_certificate_result = check_certificate()
certificateStatus = check_certificate_result["status"]
if certificateStatus == CertificateStatus.ERROR:
return False, check_certificate_result["error_code"]
if certificateStatus == CertificateStatus.NEED_REFRESH and is_first:
certificate_process = load_certificate()
if certificate_process is not True:
return False, certificate_process
return get_certificate_status(is_first=False) # recursive call to attempt certificate read
return True, check_certificate_result.get("message",
"The HTTPS certificate was generated correctly")
def get_img_name():
major, minor = get_version()[1:].split('.')
return 'iotboxv%s_%s.zip' % (major, minor)
def get_ip():
interfaces = netifaces.interfaces()
for interface in interfaces:
if netifaces.ifaddresses(interface).get(netifaces.AF_INET):
addr = netifaces.ifaddresses(interface).get(netifaces.AF_INET)[0]['addr']
if addr != '127.0.0.1':
return addr
def get_mac_address():
interfaces = netifaces.interfaces()
for interface in interfaces:
if netifaces.ifaddresses(interface).get(netifaces.AF_INET):
addr = netifaces.ifaddresses(interface).get(netifaces.AF_LINK)[0]['addr']
if addr != '00:00:00:00:00:00':
return addr
def get_path_nginx():
return str(list(Path().absolute().parent.glob('*nginx*'))[0])
def get_ssid():
ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive
if not ap:
return subprocess.check_output(['grep', '-oP', '(?<=ssid=).*', '/etc/hostapd/hostapd.conf']).decode('utf-8').rstrip()
process_iwconfig = subprocess.Popen(['iwconfig'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
process_grep = subprocess.Popen(['grep', 'ESSID:"'], stdin=process_iwconfig.stdout, stdout=subprocess.PIPE)
return subprocess.check_output(['sed', 's/.*"\\(.*\\)"/\\1/'], stdin=process_grep.stdout).decode('utf-8').rstrip()
def get_odoo_server_url():
if platform.system() == 'Linux':
ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive
if not ap:
return False
return read_file_first_line('odoo-remote-server.conf')
def get_token():
return read_file_first_line('token')
def get_commit_hash():
return subprocess.run(
['git', '--work-tree=/home/pi/odoo/', '--git-dir=/home/pi/odoo/.git', 'rev-parse', '--short', 'HEAD'],
stdout=subprocess.PIPE,
check=True,
).stdout.decode('ascii').strip()
def get_version(detailed_version=False):
if platform.system() == 'Linux':
image_version = read_file_first_line('/var/odoo/iotbox_version')
elif platform.system() == 'Windows':
# updated manually when big changes are made to the windows virtual IoT
image_version = '22.11'
version = platform.system()[0] + image_version
if detailed_version:
# Note: on windows IoT, the `release.version` finish with the build date
version += f"-{release.version}"
if platform.system() == 'Linux':
version += f'#{get_commit_hash()}'
return version
def get_wifi_essid():
wifi_options = []
process_iwlist = subprocess.Popen(['sudo', 'iwlist', 'wlan0', 'scan'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
process_grep = subprocess.Popen(['grep', 'ESSID:"'], stdin=process_iwlist.stdout, stdout=subprocess.PIPE).stdout.readlines()
for ssid in process_grep:
essid = ssid.decode('utf-8').split('"')[1]
if essid not in wifi_options:
wifi_options.append(essid)
return wifi_options
def load_certificate():
"""
Send a request to Odoo with customer db_uuid and enterprise_code to get a true certificate
"""
db_uuid = read_file_first_line('odoo-db-uuid.conf')
enterprise_code = read_file_first_line('odoo-enterprise-code.conf')
if not db_uuid:
return "ERR_IOT_HTTPS_LOAD_NO_CREDENTIAL"
url = 'https://www.odoo.com/odoo-enterprise/iot/x509'
data = {
'params': {
'db_uuid': db_uuid,
'enterprise_code': enterprise_code or ''
}
}
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE', retries=urllib3.Retry(4))
try:
response = http.request(
'POST',
url,
body = json.dumps(data).encode('utf8'),
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
)
except Exception as e:
_logger.exception("An error occurred while trying to reach odoo.com servers.")
return "ERR_IOT_HTTPS_LOAD_REQUEST_EXCEPTION\n\n%s" % e
if response.status != 200:
return "ERR_IOT_HTTPS_LOAD_REQUEST_STATUS %s\n\n%s" % (response.status, response.reason)
response_body = json.loads(response.data.decode())
server_error = response_body.get('error')
if server_error:
_logger.error("A server error received from odoo.com while trying to get the certificate: %s", server_error)
return "ERR_IOT_HTTPS_LOAD_REQUEST_NO_RESULT"
result = response_body.get('result', {})
certificate_error = result.get('error')
if certificate_error:
_logger.error("An error received from odoo.com while trying to get the certificate: %s", certificate_error)
return "ERR_IOT_HTTPS_LOAD_REQUEST_NO_RESULT"
write_file('odoo-subject.conf', result['subject_cn'])
if platform.system() == 'Linux':
with writable():
Path('/etc/ssl/certs/nginx-cert.crt').write_text(result['x509_pem'])
Path('/root_bypass_ramdisks/etc/ssl/certs/nginx-cert.crt').write_text(result['x509_pem'])
Path('/etc/ssl/private/nginx-cert.key').write_text(result['private_key_pem'])
Path('/root_bypass_ramdisks/etc/ssl/private/nginx-cert.key').write_text(result['private_key_pem'])
elif platform.system() == 'Windows':
Path(get_path_nginx()).joinpath('conf/nginx-cert.crt').write_text(result['x509_pem'])
Path(get_path_nginx()).joinpath('conf/nginx-cert.key').write_text(result['private_key_pem'])
time.sleep(3)
if platform.system() == 'Windows':
odoo_restart(0)
elif platform.system() == 'Linux':
start_nginx_server()
return True
def delete_iot_handlers():
"""
Delete all the drivers and interfaces
This is needed to avoid conflicts
with the newly downloaded drivers
"""
try:
for directory in ['drivers', 'interfaces']:
path = file_path(f'hw_drivers/iot_handlers/{directory}')
iot_handlers = list_file_by_os(path)
for file in iot_handlers:
unlink_file(f"odoo/addons/hw_drivers/iot_handlers/{directory}/{file}")
_logger.info("Deleted old IoT handlers")
except OSError:
_logger.exception('Failed to delete old IoT handlers')
def download_iot_handlers(auto=True):
"""
Get the drivers from the configured Odoo server
"""
server = get_odoo_server_url()
if server:
urllib3.disable_warnings()
pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
server = server + '/iot/get_handlers'
try:
resp = pm.request('POST', server, fields={'mac': get_mac_address(), 'auto': auto}, timeout=8)
if resp.data:
delete_iot_handlers()
with writable():
path = path_file('odoo', 'addons', 'hw_drivers', 'iot_handlers')
zip_file = zipfile.ZipFile(io.BytesIO(resp.data))
zip_file.extractall(path)
except Exception:
_logger.exception('Could not reach configured server to download IoT handlers')
def compute_iot_handlers_addon_name(handler_kind, handler_file_name):
# TODO: replace with `removesuffix` (for Odoo version using an IoT image that use Python >= 3.9)
return "odoo.addons.hw_drivers.iot_handlers.{handler_kind}.{handler_name}".\
format(handler_kind=handler_kind, handler_name=handler_file_name.replace('.py', ''))
def load_iot_handlers():
"""
This method loads local files: 'odoo/addons/hw_drivers/iot_handlers/drivers' and
'odoo/addons/hw_drivers/iot_handlers/interfaces'
And execute these python drivers and interfaces
"""
for directory in ['interfaces', 'drivers']:
path = get_resource_path('hw_drivers', 'iot_handlers', directory)
filesList = list_file_by_os(path)
for file in filesList:
spec = util.spec_from_file_location(compute_iot_handlers_addon_name(directory, file), str(Path(path).joinpath(file)))
if spec:
module = util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except Exception:
_logger.exception('Unable to load handler file: %s', file)
lazy_property.reset_all(http.root)
def list_file_by_os(file_list):
platform_os = platform.system()
if platform_os == 'Linux':
return [x.name for x in Path(file_list).glob('*[!W].*')]
elif platform_os == 'Windows':
return [x.name for x in Path(file_list).glob('*[!L].*')]
def odoo_restart(delay):
IR = IoTRestart(delay)
IR.start()
def path_file(*args):
"""Return the path to the file from IoT Box root or Windows Odoo
server folder
:return: The path to the file
"""
platform_os = platform.system()
if platform_os == 'Linux':
return Path("~pi", *args).expanduser() # Path.home() returns odoo user's home instead of pi's
elif platform_os == 'Windows':
return Path().absolute().parent.joinpath('server', *args)
def read_file_first_line(filename):
path = path_file(filename)
if path.exists():
with path.open('r') as f:
return f.readline().strip('\n')
def unlink_file(filename):
with writable():
path = path_file(filename)
if path.exists():
path.unlink()
def write_file(filename, text, mode='w'):
with writable():
path = path_file(filename)
with open(path, mode) as f:
f.write(text)
def download_from_url(download_url, path_to_filename):
"""
This function downloads from its 'download_url' argument and
saves the result in 'path_to_filename' file
The 'path_to_filename' needs to be a valid path + file name
(Example: 'C:\\Program Files\\Odoo\\downloaded_file.zip')
"""
try:
request_response = requests.get(download_url, timeout=60)
request_response.raise_for_status()
write_file(path_to_filename, request_response.content, 'wb')
_logger.info('Downloaded %s from %s', path_to_filename, download_url)
except Exception:
_logger.exception('Failed to download from %s', download_url)
def unzip_file(path_to_filename, path_to_extract):
"""
This function unzips 'path_to_filename' argument to
the path specified by 'path_to_extract' argument
and deletes the originally used .zip file
Example: unzip_file('C:\\Program Files\\Odoo\\downloaded_file.zip', 'C:\\Program Files\\Odoo\\new_folder'))
Will extract all the contents of 'downloaded_file.zip' to the 'new_folder' location)
"""
try:
with writable():
path = path_file(path_to_filename)
with zipfile.ZipFile(path) as zip_file:
zip_file.extractall(path_file(path_to_extract))
Path(path).unlink()
_logger.info('Unzipped %s to %s', path_to_filename, path_to_extract)
except Exception:
_logger.exception('Failed to unzip %s', path_to_filename)

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="pragma" content="no-cache" />
<title class="origin">{{ title or "Odoo's IoTBox" }}</title>
<script class="origin" type="text/javascript" src="/web/static/lib/jquery/jquery.js"></script>
<link class="origin" rel="stylesheet" href="/web/static/lib/bootstrap/dist/css/bootstrap.css">
<link rel="stylesheet" type="text/css" href="/web/static/src/libs/fontawesome/css/font-awesome.css"/>
<script type="text/javascript" class="origin">
var display_identifier = '{{ display_identifier }}';
{{ cust_js|safe }}
</script>
<style class="origin">
html, body {
height: 100%;
}
</style>
<style>
body {
background: linear-gradient(to right bottom, #77717e, #c9a8a9);
height: 100vh;
}
.pos-display-boxes {
position: absolute;
right: 20px;
bottom: 20px;
}
.pos-display-box {
padding: 10px 20px;
background: rgba(0, 0, 0, 0.17);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 1px 1px 0px 0px rgba(60, 60, 60, 0.4);
color: #fff;
border-radius: 8px;
width: 500px;
margin-top: 20px;
}
.pos-display-box hr {
background-color: #fff;
}
.info-text {
font-size: 15px;
}
.table-pos-info {
color: #fff;
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="text-center pt-5">
<img style="width: 150px;" src="/web/static/img/logo_inverse_white_206px.png">
<p class="mt-3" style="color: #fff;font-size: 30px;">IoTBox</p>
</div>
<div class="pos-display-boxes">
{% if pairing_code %}
<div class="pos-display-box">
<h4 class="text-center mb-3">Pairing Code</h4>
<hr/>
<h4 class="text-center mb-3">{{ pairing_code }}</h4>
</div>
{% endif %}
<div class="pos-display-box">
<h4 class="text-center mb-3">POS Client display</h4>
<table class="table table-hover table-sm table-pos-info">
<thead>
<tr>
<th>Interface</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{% for display_iface in display_ifaces -%}
<tr>
<td><i class="fa fa-{{ display_iface.icon }}"/> {{ display_iface.essid }}</td>
<td>{{ display_iface.addr }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
<p class="mb-2 info-text">
<i class="fa fa-info-circle me-1"></i>The customer cart will be displayed here once a Point of Sale session is started.
</p>
<p class="mb-2 info-text">
<i class="fa fa-info-circle me-1"></i>Odoo version 11 or above is required.
</p>
<div class="error-message alert alert-danger mb-2 d-none" role="alert" />
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,41 @@
[project]
name = "odoo-bringout-oca-ocb-hw_drivers"
version = "16.0.0"
description = "Hardware Proxy - Connect the Web Client to Hardware Peripherals"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["hw_drivers"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]

View file

@ -0,0 +1,52 @@
# ESC/POS Hardware Driver
ESC/POS Hardware Driver
=======================
This module allows Odoo to print with ESC/POS compatible printers and
to open ESC/POS controlled cashdrawers in the point of sale and other modules
that would need such functionality.
## Installation
```bash
pip install odoo-bringout-oca-ocb-hw_escpos
```
## Dependencies
This addon depends on:
## Manifest Information
- **Name**: ESC/POS Hardware Driver
- **Version**: N/A
- **Category**: Sales/Point of Sale
- **License**: LGPL-3
- **Installable**: False
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `hw_escpos`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Hw_escpos Module - hw_escpos
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for hw_escpos. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,17 @@
# Controllers
HTTP routes provided by this module.
```mermaid
sequenceDiagram
participant U as User/Client
participant C as Module Controllers
participant O as ORM/Views
U->>C: HTTP GET/POST (routes)
C->>O: ORM operations, render templates
O-->>U: HTML/JSON/PDF
```
Notes
- See files in controllers/ for route definitions.

View file

@ -0,0 +1,3 @@
# Dependencies
No explicit module dependencies declared.

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon hw_escpos or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-ocb-hw_escpos"
# or
uv pip install odoo-bringout-oca-ocb-hw_escpos"
```

View file

@ -0,0 +1,11 @@
# Models
Detected core models and extensions in hw_escpos.
```mermaid
classDiagram
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: hw_escpos. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon hw_escpos
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,8 @@
# Security
This module does not define custom security rules or access controls beyond Odoo defaults.
Default Odoo security applies:
- Base user access through standard groups
- Model access inherited from dependencies
- No custom row-level security rules

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon hw_escpos
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,4 @@
The escpos directory contains the MIT licensed pyxmlescpos lib taken from
https://github.com/fvdsn/py-xml-escpos

View file

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import escpos

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'ESC/POS Hardware Driver',
'category': 'Sales/Point of Sale',
'sequence': 6,
'website': 'https://www.odoo.com/app/point-of-sale-hardware',
'summary': 'Hardware Driver for ESC/POS Printers and Cashdrawers',
'description': """
ESC/POS Hardware Driver
=======================
This module allows Odoo to print with ESC/POS compatible printers and
to open ESC/POS controlled cashdrawers in the point of sale and other modules
that would need such functionality.
""",
'external_dependencies': {
'python' : ['pyusb','pyserial','qrcode'],
},
'installable': False,
'license': 'LGPL-3',
}

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main

View file

@ -0,0 +1,344 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from __future__ import print_function
import logging
import math
import os
import os.path
import subprocess
import time
import netifaces as ni
import traceback
escpos = printer = None
try:
from .. escpos import *
from .. escpos.exceptions import *
from .. escpos.printer import Usb
except ImportError:
pass
from queue import Queue
from threading import Thread, Lock
try:
import usb.core
except ImportError:
usb = None
from odoo import http, _
from odoo.addons.hw_drivers.controllers import proxy
_logger = logging.getLogger(__name__)
# workaround https://bugs.launchpad.net/openobject-server/+bug/947231
# related to http://bugs.python.org/issue7980
from datetime import datetime
datetime.strptime('2012-01-01', '%Y-%m-%d')
class EscposDriver(Thread):
def __init__(self):
Thread.__init__(self)
self.queue = Queue()
self.lock = Lock()
self.status = {'status':'connecting', 'messages':[]}
def connected_usb_devices(self):
connected = []
# printers can either define bDeviceClass=7, or they can define one of
# their interfaces with bInterfaceClass=7. This class checks for both.
class FindUsbClass(object):
def __init__(self, usb_class):
self._class = usb_class
def __call__(self, device):
# first, let's check the device
if device.bDeviceClass == self._class:
return True
# transverse all devices and look through their interfaces to
# find a matching class
for cfg in device:
intf = usb.util.find_descriptor(cfg, bInterfaceClass=self._class)
if intf is not None:
return True
return False
printers = usb.core.find(find_all=True, custom_match=FindUsbClass(7))
# if no printers are found after this step we will take the
# first epson or star device we can find.
# epson
if not printers:
printers = usb.core.find(find_all=True, idVendor=0x04b8)
# star
if not printers:
printers = usb.core.find(find_all=True, idVendor=0x0519)
for printer in printers:
try:
description = usb.util.get_string(printer, printer.iManufacturer) + " " + usb.util.get_string(printer, printer.iProduct)
except Exception as e:
_logger.error("Can not get printer description: %s" % e)
description = 'Unknown printer'
connected.append({
'vendor': printer.idVendor,
'product': printer.idProduct,
'name': description
})
return connected
def lockedstart(self):
with self.lock:
if not self.is_alive():
self.daemon = True
self.start()
def get_escpos_printer(self):
printers = self.connected_usb_devices()
if len(printers) > 0:
try:
print_dev = Usb(printers[0]['vendor'], printers[0]['product'])
except HandleDeviceError:
# Escpos printers are now integrated to PrinterDriver, if the IoTBox is printing
# through Cups at the same time, we get an USBError(16, 'Resource busy'). This means
# that the Odoo instance connected to this IoTBox is up to date and no longer uses
# this escpos library.
return None
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
def get_status(self):
self.push_task('status')
return self.status
def open_cashbox(self,printer):
printer.cashdraw(2)
printer.cashdraw(5)
def set_status(self, status, message = None):
_logger.info(status+' : '+ (message or 'no message'))
if status == self.status['status']:
if message != None and (len(self.status['messages']) == 0 or message != self.status['messages'][-1]):
self.status['messages'].append(message)
else:
self.status['status'] = status
if message:
self.status['messages'] = [message]
else:
self.status['messages'] = []
if status == 'error' and message:
_logger.error('ESC/POS Error: %s', message)
elif status == 'disconnected' and message:
_logger.warning('ESC/POS Device Disconnected: %s', message)
def run(self):
printer = None
if not escpos:
_logger.error('ESC/POS cannot initialize, please verify system dependencies.')
return
while True:
error = True
try:
timestamp, task, data = self.queue.get(True)
printer = self.get_escpos_printer()
if printer == None:
if task != 'status':
self.queue.put((timestamp,task,data))
error = False
time.sleep(5)
continue
elif task == 'receipt':
if timestamp >= time.time() - 1 * 60 * 60:
self.print_receipt_body(printer,data)
printer.cut()
elif task == 'xml_receipt':
if timestamp >= time.time() - 1 * 60 * 60:
printer.receipt(data)
elif task == 'cashbox':
if timestamp >= time.time() - 12:
self.open_cashbox(printer)
elif task == 'status':
pass
error = False
except NoDeviceError as e:
print("No device found %s" % e)
except HandleDeviceError as e:
printer = None
print("Impossible to handle the device due to previous error %s" % e)
except TicketNotPrinted as e:
print("The ticket does not seems to have been fully printed %s" % e)
except NoStatusError as e:
print("Impossible to get the status of the printer %s" % e)
except Exception as e:
self.set_status('error')
_logger.exception(e)
finally:
if error:
self.queue.put((timestamp, task, data))
if printer:
printer.close()
printer = None
def push_task(self,task, data = None):
self.lockedstart()
self.queue.put((time.time(),task,data))
def print_receipt_body(self,eprint,receipt):
def check(string):
return string != True and bool(string) and string.strip()
def price(amount):
return ("{0:."+str(receipt['precision']['price'])+"f}").format(amount)
def money(amount):
return ("{0:."+str(receipt['precision']['money'])+"f}").format(amount)
def quantity(amount):
if math.floor(amount) != amount:
return ("{0:."+str(receipt['precision']['quantity'])+"f}").format(amount)
else:
return str(amount)
def printline(left, right='', width=40, ratio=0.5, indent=0):
lwidth = int(width * ratio)
rwidth = width - lwidth
lwidth = lwidth - indent
left = left[:lwidth]
if len(left) != lwidth:
left = left + ' ' * (lwidth - len(left))
right = right[-rwidth:]
if len(right) != rwidth:
right = ' ' * (rwidth - len(right)) + right
return ' ' * indent + left + right + '\n'
def print_taxes():
taxes = receipt['tax_details']
for tax in taxes:
eprint.text(printline(tax['tax']['name'],price(tax['amount']), width=40,ratio=0.6))
# Receipt Header
if receipt['company']['logo']:
eprint.set(align='center')
eprint.print_base64_image(receipt['company']['logo'])
eprint.text('\n')
else:
eprint.set(align='center',type='b',height=2,width=2)
eprint.text(receipt['company']['name'] + '\n')
eprint.set(align='center',type='b')
if check(receipt['company']['contact_address']):
eprint.text(receipt['company']['contact_address'] + '\n')
if check(receipt['company']['phone']):
eprint.text('Tel:' + receipt['company']['phone'] + '\n')
if check(receipt['company']['vat']):
eprint.text('VAT:' + receipt['company']['vat'] + '\n')
if check(receipt['company']['email']):
eprint.text(receipt['company']['email'] + '\n')
if check(receipt['company']['website']):
eprint.text(receipt['company']['website'] + '\n')
if check(receipt['header']):
eprint.text(receipt['header']+'\n')
if check(receipt['cashier']):
eprint.text('-'*32+'\n')
eprint.text('Served by '+receipt['cashier']+'\n')
# Orderlines
eprint.text('\n\n')
eprint.set(align='center')
for line in receipt['orderlines']:
pricestr = price(line['price_display'])
if line['discount'] == 0 and line['unit_name'] == 'Units' and line['quantity'] == 1:
eprint.text(printline(line['product_name'],pricestr,ratio=0.6))
else:
eprint.text(printline(line['product_name'],ratio=0.6))
if line['discount'] != 0:
eprint.text(printline('Discount: '+str(line['discount'])+'%', ratio=0.6, indent=2))
if line['unit_name'] == 'Units':
eprint.text( printline( quantity(line['quantity']) + ' x ' + price(line['price']), pricestr, ratio=0.6, indent=2))
else:
eprint.text( printline( quantity(line['quantity']) + line['unit_name'] + ' x ' + price(line['price']), pricestr, ratio=0.6, indent=2))
# Subtotal if the taxes are not included
taxincluded = True
if money(receipt['subtotal']) != money(receipt['total_with_tax']):
eprint.text(printline('', '-------'))
eprint.text(printline(_('Subtotal'),money(receipt['subtotal']),width=40, ratio=0.6))
print_taxes()
#eprint.text(printline(_('Taxes'),money(receipt['total_tax']),width=40, ratio=0.6))
taxincluded = False
# Total
eprint.text(printline('', '-------'))
eprint.set(align='center',height=2)
eprint.text(printline(_(' TOTAL'),money(receipt['total_with_tax']),width=40, ratio=0.6))
eprint.text('\n\n')
# Paymentlines
eprint.set(align='center')
for line in receipt['paymentlines']:
eprint.text(printline(line['journal'], money(line['amount']), ratio=0.6))
eprint.text('\n')
eprint.set(align='center',height=2)
eprint.text(printline(_(' CHANGE'),money(receipt['change']),width=40, ratio=0.6))
eprint.set(align='center')
eprint.text('\n')
# Extra Payment info
if receipt['total_discount'] != 0:
eprint.text(printline(_('Discounts'),money(receipt['total_discount']),width=40, ratio=0.6))
if taxincluded:
print_taxes()
#eprint.text(printline(_('Taxes'),money(receipt['total_tax']),width=40, ratio=0.6))
# Footer
if check(receipt['footer']):
eprint.text('\n'+receipt['footer']+'\n\n')
eprint.text(receipt['name']+'\n')
eprint.text( str(receipt['date']['date']).zfill(2)
+'/'+ str(receipt['date']['month']+1).zfill(2)
+'/'+ str(receipt['date']['year']).zfill(4)
+' '+ str(receipt['date']['hour']).zfill(2)
+':'+ str(receipt['date']['minute']).zfill(2) )
driver = EscposDriver()
proxy.proxy_drivers['escpos'] = driver
class EscposProxy(proxy.ProxyController):
@http.route('/hw_proxy/open_cashbox', type='json', auth='none', cors='*')
def open_cashbox(self):
_logger.info('ESC/POS: OPEN CASHBOX')
driver.push_task('cashbox')
@http.route('/hw_proxy/print_receipt', type='json', auth='none', cors='*')
def print_receipt(self, receipt):
_logger.info('ESC/POS: PRINT RECEIPT')
driver.push_task('receipt',receipt)
@http.route('/hw_proxy/print_xml_receipt', type='json', auth='none', cors='*')
def print_xml_receipt(self, receipt):
_logger.info('ESC/POS: PRINT XML RECEIPT')
driver.push_task('xml_receipt',receipt)

View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Frederic van der Essen & Manuel F. Martinez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -0,0 +1 @@
__all__ = ["constants","escpos","exceptions","printer"]

View file

@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
""" ESC/POS Commands (Constants) """
# Control characters
ESC = '\x1b'
# Feed control sequences
CTL_LF = '\x0a' # Print and line feed
CTL_FF = '\x0c' # Form feed
CTL_CR = '\x0d' # Carriage return
CTL_HT = '\x09' # Horizontal tab
CTL_VT = '\x0b' # Vertical tab
# RT Status commands
DLE_EOT_PRINTER = '\x10\x04\x01' # Transmit printer status
DLE_EOT_OFFLINE = '\x10\x04\x02'
DLE_EOT_ERROR = '\x10\x04\x03'
DLE_EOT_PAPER = '\x10\x04\x04'
# Printer hardware
HW_INIT = '\x1b\x40' # Clear data in buffer and reset modes
HW_SELECT = '\x1b\x3d\x01' # Printer select
HW_RESET = '\x1b\x3f\x0a\x00' # Reset printer hardware
# Cash Drawer (ESC p <pin> <on time: 2*ms> <off time: 2*ms>)
_CASH_DRAWER = lambda m, t1='', t2='': ESC + 'p' + m + chr(t1) + chr(t2)
CD_KICK_2 = _CASH_DRAWER('\x00', 50, 50) # Sends a pulse to pin 2 []
CD_KICK_5 = _CASH_DRAWER('\x01', 50, 50) # Sends a pulse to pin 5 []
# Paper
PAPER_FULL_CUT = '\x1d\x56\x00' # Full cut paper
PAPER_PART_CUT = '\x1d\x56\x01' # Partial cut paper
# Text format
TXT_NORMAL = '\x1b\x21\x00' # Normal text
TXT_2HEIGHT = '\x1b\x21\x10' # Double height text
TXT_2WIDTH = '\x1b\x21\x20' # Double width text
TXT_DOUBLE = '\x1b\x21\x30' # Double height & Width
TXT_UNDERL_OFF = '\x1b\x2d\x00' # Underline font OFF
TXT_UNDERL_ON = '\x1b\x2d\x01' # Underline font 1-dot ON
TXT_UNDERL2_ON = '\x1b\x2d\x02' # Underline font 2-dot ON
TXT_BOLD_OFF = '\x1b\x45\x00' # Bold font OFF
TXT_BOLD_ON = '\x1b\x45\x01' # Bold font ON
TXT_FONT_A = '\x1b\x4d\x00' # Font type A
TXT_FONT_B = '\x1b\x4d\x01' # Font type B
TXT_ALIGN_LT = '\x1b\x61\x00' # Left justification
TXT_ALIGN_CT = '\x1b\x61\x01' # Centering
TXT_ALIGN_RT = '\x1b\x61\x02' # Right justification
TXT_COLOR_BLACK = '\x1b\x72\x00' # Default Color
TXT_COLOR_RED = '\x1b\x72\x01' # Alternative Color ( Usually Red )
# Text Encoding
TXT_ENC_PC437 = '\x1b\x74\x00' # PC437 USA
TXT_ENC_KATAKANA= '\x1b\x74\x01' # KATAKANA (JAPAN)
TXT_ENC_PC850 = '\x1b\x74\x02' # PC850 Multilingual
TXT_ENC_PC860 = '\x1b\x74\x03' # PC860 Portuguese
TXT_ENC_PC863 = '\x1b\x74\x04' # PC863 Canadian-French
TXT_ENC_PC865 = '\x1b\x74\x05' # PC865 Nordic
TXT_ENC_KANJI6 = '\x1b\x74\x06' # One-pass Kanji, Hiragana
TXT_ENC_KANJI7 = '\x1b\x74\x07' # One-pass Kanji
TXT_ENC_KANJI8 = '\x1b\x74\x08' # One-pass Kanji
TXT_ENC_PC851 = '\x1b\x74\x0b' # PC851 Greek
TXT_ENC_PC853 = '\x1b\x74\x0c' # PC853 Turkish
TXT_ENC_PC857 = '\x1b\x74\x0d' # PC857 Turkish
TXT_ENC_PC737 = '\x1b\x74\x0e' # PC737 Greek
TXT_ENC_8859_7 = '\x1b\x74\x0f' # ISO8859-7 Greek
TXT_ENC_WPC1252 = '\x1b\x74\x10' # WPC1252
TXT_ENC_PC866 = '\x1b\x74\x11' # PC866 Cyrillic #2
TXT_ENC_PC852 = '\x1b\x74\x12' # PC852 Latin2
TXT_ENC_PC858 = '\x1b\x74\x13' # PC858 Euro
TXT_ENC_KU42 = '\x1b\x74\x14' # KU42 Thai
TXT_ENC_TIS11 = '\x1b\x74\x15' # TIS11 Thai
TXT_ENC_TIS18 = '\x1b\x74\x1a' # TIS18 Thai
TXT_ENC_TCVN3 = '\x1b\x74\x1e' # TCVN3 Vietnamese
TXT_ENC_TCVN3B = '\x1b\x74\x1f' # TCVN3 Vietnamese
TXT_ENC_PC720 = '\x1b\x74\x20' # PC720 Arabic
TXT_ENC_WPC775 = '\x1b\x74\x21' # WPC775 Baltic Rim
TXT_ENC_PC855 = '\x1b\x74\x22' # PC855 Cyrillic
TXT_ENC_PC861 = '\x1b\x74\x23' # PC861 Icelandic
TXT_ENC_PC862 = '\x1b\x74\x24' # PC862 Hebrew
TXT_ENC_PC864 = '\x1b\x74\x25' # PC864 Arabic
TXT_ENC_PC869 = '\x1b\x74\x26' # PC869 Greek
TXT_ENC_PC936 = '\x1C\x21\x00' # PC936 GBK(Guobiao Kuozhan)
TXT_ENC_8859_2 = '\x1b\x74\x27' # ISO8859-2 Latin2
TXT_ENC_8859_9 = '\x1b\x74\x28' # ISO8859-2 Latin9
TXT_ENC_PC1098 = '\x1b\x74\x29' # PC1098 Farsi
TXT_ENC_PC1118 = '\x1b\x74\x2a' # PC1118 Lithuanian
TXT_ENC_PC1119 = '\x1b\x74\x2b' # PC1119 Lithuanian
TXT_ENC_PC1125 = '\x1b\x74\x2c' # PC1125 Ukrainian
TXT_ENC_WPC1250 = '\x1b\x74\x2d' # WPC1250 Latin2
TXT_ENC_WPC1251 = '\x1b\x74\x2e' # WPC1251 Cyrillic
TXT_ENC_WPC1253 = '\x1b\x74\x2f' # WPC1253 Greek
TXT_ENC_WPC1254 = '\x1b\x74\x30' # WPC1254 Turkish
TXT_ENC_WPC1255 = '\x1b\x74\x31' # WPC1255 Hebrew
TXT_ENC_WPC1256 = '\x1b\x74\x32' # WPC1256 Arabic
TXT_ENC_WPC1257 = '\x1b\x74\x33' # WPC1257 Baltic Rim
TXT_ENC_WPC1258 = '\x1b\x74\x34' # WPC1258 Vietnamese
TXT_ENC_KZ1048 = '\x1b\x74\x35' # KZ-1048 Kazakhstan
TXT_ENC_KATAKANA_MAP = {
# Maps UTF-8 Katakana symbols to KATAKANA Page Codes
# Half-Width Katakanas
'\xef\xbd\xa1':'\xa1', # 。
'\xef\xbd\xa2':'\xa2', # 「
'\xef\xbd\xa3':'\xa3', # 」
'\xef\xbd\xa4':'\xa4', # 、
'\xef\xbd\xa5':'\xa5', # ・
'\xef\xbd\xa6':'\xa6', # ヲ
'\xef\xbd\xa7':'\xa7', # ァ
'\xef\xbd\xa8':'\xa8', # ィ
'\xef\xbd\xa9':'\xa9', # ゥ
'\xef\xbd\xaa':'\xaa', # ェ
'\xef\xbd\xab':'\xab', # ォ
'\xef\xbd\xac':'\xac', # ャ
'\xef\xbd\xad':'\xad', # ュ
'\xef\xbd\xae':'\xae', # ョ
'\xef\xbd\xaf':'\xaf', # ッ
'\xef\xbd\xb0':'\xb0', # ー
'\xef\xbd\xb1':'\xb1', # ア
'\xef\xbd\xb2':'\xb2', # イ
'\xef\xbd\xb3':'\xb3', # ウ
'\xef\xbd\xb4':'\xb4', # エ
'\xef\xbd\xb5':'\xb5', # オ
'\xef\xbd\xb6':'\xb6', # カ
'\xef\xbd\xb7':'\xb7', # キ
'\xef\xbd\xb8':'\xb8', # ク
'\xef\xbd\xb9':'\xb9', # ケ
'\xef\xbd\xba':'\xba', # コ
'\xef\xbd\xbb':'\xbb', # サ
'\xef\xbd\xbc':'\xbc', # シ
'\xef\xbd\xbd':'\xbd', # ス
'\xef\xbd\xbe':'\xbe', # セ
'\xef\xbd\xbf':'\xbf', # ソ
'\xef\xbe\x80':'\xc0', # タ
'\xef\xbe\x81':'\xc1', # チ
'\xef\xbe\x82':'\xc2', # ツ
'\xef\xbe\x83':'\xc3', # テ
'\xef\xbe\x84':'\xc4', # ト
'\xef\xbe\x85':'\xc5', # ナ
'\xef\xbe\x86':'\xc6', # ニ
'\xef\xbe\x87':'\xc7', # ヌ
'\xef\xbe\x88':'\xc8', # ネ
'\xef\xbe\x89':'\xc9', # ノ
'\xef\xbe\x8a':'\xca', # ハ
'\xef\xbe\x8b':'\xcb', # ヒ
'\xef\xbe\x8c':'\xcc', # フ
'\xef\xbe\x8d':'\xcd', # ヘ
'\xef\xbe\x8e':'\xce', # ホ
'\xef\xbe\x8f':'\xcf', # マ
'\xef\xbe\x90':'\xd0', # ミ
'\xef\xbe\x91':'\xd1', # ム
'\xef\xbe\x92':'\xd2', # メ
'\xef\xbe\x93':'\xd3', # モ
'\xef\xbe\x94':'\xd4', # ヤ
'\xef\xbe\x95':'\xd5', # ユ
'\xef\xbe\x96':'\xd6', # ヨ
'\xef\xbe\x97':'\xd7', # ラ
'\xef\xbe\x98':'\xd8', # リ
'\xef\xbe\x99':'\xd9', # ル
'\xef\xbe\x9a':'\xda', # レ
'\xef\xbe\x9b':'\xdb', # ロ
'\xef\xbe\x9c':'\xdc', # ワ
'\xef\xbe\x9d':'\xdd', # ン
'\xef\xbe\x9e':'\xde', # ゙
'\xef\xbe\x9f':'\xdf', # ゚
}
# Barcod format
BARCODE_TXT_OFF = '\x1d\x48\x00' # HRI barcode chars OFF
BARCODE_TXT_ABV = '\x1d\x48\x01' # HRI barcode chars above
BARCODE_TXT_BLW = '\x1d\x48\x02' # HRI barcode chars below
BARCODE_TXT_BTH = '\x1d\x48\x03' # HRI barcode chars both above and below
BARCODE_FONT_A = '\x1d\x66\x00' # Font type A for HRI barcode chars
BARCODE_FONT_B = '\x1d\x66\x01' # Font type B for HRI barcode chars
BARCODE_HEIGHT = '\x1d\x68\x64' # Barcode Height [1-255]
BARCODE_WIDTH = '\x1d\x77\x03' # Barcode Width [2-6]
BARCODE_UPC_A = '\x1d\x6b\x00' # Barcode type UPC-A
BARCODE_UPC_E = '\x1d\x6b\x01' # Barcode type UPC-E
BARCODE_EAN13 = '\x1d\x6b\x02' # Barcode type EAN13
BARCODE_EAN8 = '\x1d\x6b\x03' # Barcode type EAN8
BARCODE_CODE39 = '\x1d\x6b\x04' # Barcode type CODE39
BARCODE_ITF = '\x1d\x6b\x05' # Barcode type ITF
BARCODE_NW7 = '\x1d\x6b\x06' # Barcode type NW7
# Image format
S_RASTER_N = '\x1d\x76\x30\x00' # Set raster image normal size
S_RASTER_2W = '\x1d\x76\x30\x01' # Set raster image double width
S_RASTER_2H = '\x1d\x76\x30\x02' # Set raster image double height
S_RASTER_Q = '\x1d\x76\x30\x03' # Set raster image quadruple

View file

@ -0,0 +1,935 @@
# -*- coding: utf-8 -*-
from __future__ import print_function
import base64
import copy
import io
import math
import re
import traceback
import codecs
from hashlib import md5
from PIL import Image
from xml.etree import ElementTree as ET
try:
import jcconv
except ImportError:
jcconv = None
try:
import qrcode
except ImportError:
qrcode = None
from .constants import *
from .exceptions import *
def utfstr(stuff):
""" converts stuff to string and does without failing if stuff is a utf8 string """
if isinstance(stuff, str):
return stuff
else:
return str(stuff)
class StyleStack:
"""
The stylestack is used by the xml receipt serializer to compute the active styles along the xml
document. Styles are just xml attributes, there is no css mechanism. But the style applied by
the attributes are inherited by deeper nodes.
"""
def __init__(self):
self.stack = []
self.defaults = { # default style values
'align': 'left',
'underline': 'off',
'bold': 'off',
'size': 'normal',
'font' : 'a',
'width': 48,
'indent': 0,
'tabwidth': 2,
'bullet': ' - ',
'line-ratio':0.5,
'color': 'black',
'value-decimals': 2,
'value-symbol': '',
'value-symbol-position': 'after',
'value-autoint': 'off',
'value-decimals-separator': '.',
'value-thousands-separator': ',',
'value-width': 0,
}
self.types = { # attribute types, default is string and can be ommitted
'width': 'int',
'indent': 'int',
'tabwidth': 'int',
'line-ratio': 'float',
'value-decimals': 'int',
'value-width': 'int',
}
self.cmds = {
# translation from styles to escpos commands
# some style do not correspond to escpos command are used by
# the serializer instead
'align': {
'left': TXT_ALIGN_LT,
'right': TXT_ALIGN_RT,
'center': TXT_ALIGN_CT,
'_order': 1,
},
'underline': {
'off': TXT_UNDERL_OFF,
'on': TXT_UNDERL_ON,
'double': TXT_UNDERL2_ON,
# must be issued after 'size' command
# because ESC ! resets ESC -
'_order': 10,
},
'bold': {
'off': TXT_BOLD_OFF,
'on': TXT_BOLD_ON,
# must be issued after 'size' command
# because ESC ! resets ESC -
'_order': 10,
},
'font': {
'a': TXT_FONT_A,
'b': TXT_FONT_B,
# must be issued after 'size' command
# because ESC ! resets ESC -
'_order': 10,
},
'size': {
'normal': TXT_NORMAL,
'double-height': TXT_2HEIGHT,
'double-width': TXT_2WIDTH,
'double': TXT_DOUBLE,
'_order': 1,
},
'color': {
'black': TXT_COLOR_BLACK,
'red': TXT_COLOR_RED,
'_order': 1,
},
}
self.push(self.defaults)
def get(self,style):
""" what's the value of a style at the current stack level"""
level = len(self.stack) -1
while level >= 0:
if style in self.stack[level]:
return self.stack[level][style]
else:
level = level - 1
return None
def enforce_type(self, attr, val):
"""converts a value to the attribute's type"""
if not attr in self.types:
return utfstr(val)
elif self.types[attr] == 'int':
return int(float(val))
elif self.types[attr] == 'float':
return float(val)
else:
return utfstr(val)
def push(self, style={}):
"""push a new level on the stack with a style dictionnary containing style:value pairs"""
_style = {}
for attr in style:
if attr in self.cmds and not style[attr] in self.cmds[attr]:
print('WARNING: ESC/POS PRINTING: ignoring invalid value: %s for style %s' % (style[attr], utfstr(attr)))
else:
_style[attr] = self.enforce_type(attr, style[attr])
self.stack.append(_style)
def set(self, style={}):
"""overrides style values at the current stack level"""
_style = {}
for attr in style:
if attr in self.cmds and not style[attr] in self.cmds[attr]:
print('WARNING: ESC/POS PRINTING: ignoring invalid value: %s for style %s' % (style[attr], attr))
else:
self.stack[-1][attr] = self.enforce_type(attr, style[attr])
def pop(self):
""" pop a style stack level """
if len(self.stack) > 1 :
self.stack = self.stack[:-1]
def to_escpos(self):
""" converts the current style to an escpos command string """
cmd = ''
ordered_cmds = sorted(self.cmds, key=lambda x: self.cmds[x]['_order'])
for style in ordered_cmds:
cmd += self.cmds[style][self.get(style)]
return cmd
class XmlSerializer:
"""
Converts the xml inline / block tree structure to a string,
keeping track of newlines and spacings.
The string is outputted asap to the provided escpos driver.
"""
def __init__(self,escpos):
self.escpos = escpos
self.stack = ['block']
self.dirty = False
def start_inline(self,stylestack=None):
""" starts an inline entity with an optional style definition """
self.stack.append('inline')
if self.dirty:
self.escpos._raw(' ')
if stylestack:
self.style(stylestack)
def start_block(self,stylestack=None):
""" starts a block entity with an optional style definition """
if self.dirty:
self.escpos._raw('\n')
self.dirty = False
self.stack.append('block')
if stylestack:
self.style(stylestack)
def end_entity(self):
""" ends the entity definition. (but does not cancel the active style!) """
if self.stack[-1] == 'block' and self.dirty:
self.escpos._raw('\n')
self.dirty = False
if len(self.stack) > 1:
self.stack = self.stack[:-1]
def pre(self,text):
""" puts a string of text in the entity keeping the whitespace intact """
if text:
self.escpos.text(text)
self.dirty = True
def text(self,text):
""" puts text in the entity. Whitespace and newlines are stripped to single spaces. """
if text:
text = utfstr(text)
text = text.strip()
text = re.sub(r'\s+', ' ', text)
if text:
self.dirty = True
self.escpos.text(text)
def linebreak(self):
""" inserts a linebreak in the entity """
self.dirty = False
self.escpos._raw('\n')
def style(self,stylestack):
""" apply a style to the entity (only applies to content added after the definition) """
self.raw(stylestack.to_escpos())
def raw(self,raw):
""" puts raw text or escpos command in the entity without affecting the state of the serializer """
self.escpos._raw(raw)
class XmlLineSerializer:
"""
This is used to convert a xml tree into a single line, with a left and a right part.
The content is not output to escpos directly, and is intended to be fedback to the
XmlSerializer as the content of a block entity.
"""
def __init__(self, indent=0, tabwidth=2, width=48, ratio=0.5):
self.tabwidth = tabwidth
self.indent = indent
self.width = max(0, width - int(tabwidth*indent))
self.lwidth = int(self.width*ratio)
self.rwidth = max(0, self.width - self.lwidth)
self.clwidth = 0
self.crwidth = 0
self.lbuffer = ''
self.rbuffer = ''
self.left = True
def _txt(self,txt):
if self.left:
if self.clwidth < self.lwidth:
txt = txt[:max(0, self.lwidth - self.clwidth)]
self.lbuffer += txt
self.clwidth += len(txt)
else:
if self.crwidth < self.rwidth:
txt = txt[:max(0, self.rwidth - self.crwidth)]
self.rbuffer += txt
self.crwidth += len(txt)
def start_inline(self,stylestack=None):
if (self.left and self.clwidth) or (not self.left and self.crwidth):
self._txt(' ')
def start_block(self,stylestack=None):
self.start_inline(stylestack)
def end_entity(self):
pass
def pre(self,text):
if text:
self._txt(text)
def text(self,text):
if text:
text = utfstr(text)
text = text.strip()
text = re.sub(r'\s+', ' ', text)
if text:
self._txt(text)
def linebreak(self):
pass
def style(self,stylestack):
pass
def raw(self,raw):
pass
def start_right(self):
self.left = False
def get_line(self):
return ' ' * self.indent * self.tabwidth + self.lbuffer + ' ' * (self.width - self.clwidth - self.crwidth) + self.rbuffer
class Escpos:
""" ESC/POS Printer object """
device = None
encoding = None
img_cache = {}
def _check_image_size(self, size):
""" Check and fix the size of the image to 32 bits """
if size % 32 == 0:
return (0, 0)
else:
image_border = 32 - (size % 32)
if (image_border % 2) == 0:
return (int(image_border / 2), int(image_border / 2))
else:
return (int(image_border / 2), int((image_border / 2) + 1))
def _print_image(self, line, size):
""" Print formatted image """
i = 0
cont = 0
buffer = ""
self._raw(S_RASTER_N)
buffer = b"%02X%02X%02X%02X" % (int((size[0]/size[1])/8), 0, size[1], 0)
self._raw(codecs.decode(buffer, 'hex'))
buffer = ""
while i < len(line):
hex_string = int(line[i:i+8],2)
buffer += "%02X" % hex_string
i += 8
cont += 1
if cont % 4 == 0:
self._raw(codecs.decode(buffer, "hex"))
buffer = ""
cont = 0
def _raw_print_image(self, line, size, output=None ):
""" Print formatted image """
i = 0
cont = 0
buffer = ""
raw = b""
def __raw(string):
if output:
output(string)
else:
self._raw(string)
raw += S_RASTER_N.encode('utf-8')
buffer = "%02X%02X%02X%02X" % (int((size[0]/size[1])/8), 0, size[1], 0)
raw += codecs.decode(buffer, 'hex')
buffer = ""
while i < len(line):
hex_string = int(line[i:i+8],2)
buffer += "%02X" % hex_string
i += 8
cont += 1
if cont % 4 == 0:
raw += codecs.decode(buffer, 'hex')
buffer = ""
cont = 0
return raw
def _convert_image(self, im):
""" Parse image and prepare it to a printable format """
pixels = []
pix_line = ""
im_left = ""
im_right = ""
switch = 0
img_size = [ 0, 0 ]
if im.size[0] > 512:
print("WARNING: Image is wider than 512 and could be truncated at print time ")
if im.size[1] > 255:
raise ImageSizeError()
im_border = self._check_image_size(im.size[0])
for i in range(im_border[0]):
im_left += "0"
for i in range(im_border[1]):
im_right += "0"
for y in range(im.size[1]):
img_size[1] += 1
pix_line += im_left
img_size[0] += im_border[0]
for x in range(im.size[0]):
img_size[0] += 1
RGB = im.getpixel((x, y))
im_color = (RGB[0] + RGB[1] + RGB[2])
im_pattern = "1X0"
pattern_len = len(im_pattern)
switch = (switch - 1 ) * (-1)
for x in range(pattern_len):
if im_color <= (255 * 3 / pattern_len * (x+1)):
if im_pattern[x] == "X":
pix_line += "%d" % switch
else:
pix_line += im_pattern[x]
break
elif im_color > (255 * 3 / pattern_len * pattern_len) and im_color <= (255 * 3):
pix_line += im_pattern[-1]
break
pix_line += im_right
img_size[0] += im_border[1]
return (pix_line, img_size)
def image(self,path_img):
""" Open image file """
im_open = Image.open(path_img)
im = im_open.convert("RGB")
# Convert the RGB image in printable image
pix_line, img_size = self._convert_image(im)
self._print_image(pix_line, img_size)
def print_base64_image(self,img):
print('print_b64_img')
id = md5(img).digest()
if id not in self.img_cache:
print('not in cache')
img = img[img.find(b',')+1:]
f = io.BytesIO(b'img')
f.write(base64.decodebytes(img))
f.seek(0)
img_rgba = Image.open(f)
img = Image.new('RGB', img_rgba.size, (255,255,255))
channels = img_rgba.split()
if len(channels) > 3:
# use alpha channel as mask
img.paste(img_rgba, mask=channels[3])
else:
img.paste(img_rgba)
print('convert image')
pix_line, img_size = self._convert_image(img)
print('print image')
buffer = self._raw_print_image(pix_line, img_size)
self.img_cache[id] = buffer
print('raw image')
self._raw(self.img_cache[id])
def qr(self,text):
""" Print QR Code for the provided string """
qr_code = qrcode.QRCode(version=4, box_size=4, border=1)
qr_code.add_data(text)
qr_code.make(fit=True)
qr_img = qr_code.make_image()
im = qr_img._img.convert("RGB")
# Convert the RGB image in printable image
self._convert_image(im)
def barcode(self, code, bc, width=255, height=2, pos='below', font='a'):
""" Print Barcode """
# Align Bar Code()
self._raw(TXT_ALIGN_CT)
# Height
if height >=2 or height <=6:
self._raw(BARCODE_HEIGHT)
else:
raise BarcodeSizeError()
# Width
if width >= 1 or width <=255:
self._raw(BARCODE_WIDTH)
else:
raise BarcodeSizeError()
# Font
if font.upper() == "B":
self._raw(BARCODE_FONT_B)
else: # DEFAULT FONT: A
self._raw(BARCODE_FONT_A)
# Position
if pos.upper() == "OFF":
self._raw(BARCODE_TXT_OFF)
elif pos.upper() == "BOTH":
self._raw(BARCODE_TXT_BTH)
elif pos.upper() == "ABOVE":
self._raw(BARCODE_TXT_ABV)
else: # DEFAULT POSITION: BELOW
self._raw(BARCODE_TXT_BLW)
# Type
if bc.upper() == "UPC-A":
self._raw(BARCODE_UPC_A)
elif bc.upper() == "UPC-E":
self._raw(BARCODE_UPC_E)
elif bc.upper() == "EAN13":
self._raw(BARCODE_EAN13)
elif bc.upper() == "EAN8":
self._raw(BARCODE_EAN8)
elif bc.upper() == "CODE39":
self._raw(BARCODE_CODE39)
elif bc.upper() == "ITF":
self._raw(BARCODE_ITF)
elif bc.upper() == "NW7":
self._raw(BARCODE_NW7)
else:
raise BarcodeTypeError()
# Print Code
if code:
self._raw(code)
# We are using type A commands
# So we need to add the 'NULL' character
# https://github.com/python-escpos/python-escpos/pull/98/files#diff-a0b1df12c7c67e38915adbe469051e2dR444
self._raw('\x00')
else:
raise BarcodeCodeError()
def receipt(self,xml):
"""
Prints an xml based receipt definition
"""
def strclean(string):
if not string:
string = ''
string = string.strip()
string = re.sub(r'\s+', ' ', string)
return string
def format_value(value, decimals=3, width=0, decimals_separator='.', thousands_separator=',', autoint=False, symbol='', position='after'):
decimals = max(0,int(decimals))
width = max(0,int(width))
value = float(value)
if autoint and math.floor(value) == value:
decimals = 0
if width == 0:
width = ''
if thousands_separator:
formatstr = "{:"+str(width)+",."+str(decimals)+"f}"
else:
formatstr = "{:"+str(width)+"."+str(decimals)+"f}"
ret = formatstr.format(value)
ret = ret.replace(',','COMMA')
ret = ret.replace('.','DOT')
ret = ret.replace('COMMA',thousands_separator)
ret = ret.replace('DOT',decimals_separator)
if symbol:
if position == 'after':
ret = ret + symbol
else:
ret = symbol + ret
return ret
def print_elem(stylestack, serializer, elem, indent=0):
elem_styles = {
'h1': {'bold': 'on', 'size':'double'},
'h2': {'size':'double'},
'h3': {'bold': 'on', 'size':'double-height'},
'h4': {'size': 'double-height'},
'h5': {'bold': 'on'},
'em': {'font': 'b'},
'b': {'bold': 'on'},
}
stylestack.push()
if elem.tag in elem_styles:
stylestack.set(elem_styles[elem.tag])
stylestack.set(elem.attrib)
if elem.tag in ('p','div','section','article','receipt','header','footer','li','h1','h2','h3','h4','h5'):
serializer.start_block(stylestack)
serializer.text(elem.text)
for child in elem:
print_elem(stylestack,serializer,child)
serializer.start_inline(stylestack)
serializer.text(child.tail)
serializer.end_entity()
serializer.end_entity()
elif elem.tag in ('span','em','b','left','right'):
serializer.start_inline(stylestack)
serializer.text(elem.text)
for child in elem:
print_elem(stylestack,serializer,child)
serializer.start_inline(stylestack)
serializer.text(child.tail)
serializer.end_entity()
serializer.end_entity()
elif elem.tag == 'value':
serializer.start_inline(stylestack)
serializer.pre(format_value(
elem.text,
decimals=stylestack.get('value-decimals'),
width=stylestack.get('value-width'),
decimals_separator=stylestack.get('value-decimals-separator'),
thousands_separator=stylestack.get('value-thousands-separator'),
autoint=(stylestack.get('value-autoint') == 'on'),
symbol=stylestack.get('value-symbol'),
position=stylestack.get('value-symbol-position')
))
serializer.end_entity()
elif elem.tag == 'line':
width = stylestack.get('width')
if stylestack.get('size') in ('double', 'double-width'):
width = width / 2
lineserializer = XmlLineSerializer(stylestack.get('indent')+indent,stylestack.get('tabwidth'),width,stylestack.get('line-ratio'))
serializer.start_block(stylestack)
for child in elem:
if child.tag == 'left':
print_elem(stylestack,lineserializer,child,indent=indent)
elif child.tag == 'right':
lineserializer.start_right()
print_elem(stylestack,lineserializer,child,indent=indent)
serializer.pre(lineserializer.get_line())
serializer.end_entity()
elif elem.tag == 'ul':
serializer.start_block(stylestack)
bullet = stylestack.get('bullet')
for child in elem:
if child.tag == 'li':
serializer.style(stylestack)
serializer.raw(' ' * indent * stylestack.get('tabwidth') + bullet)
print_elem(stylestack,serializer,child,indent=indent+1)
serializer.end_entity()
elif elem.tag == 'ol':
cwidth = len(str(len(elem))) + 2
i = 1
serializer.start_block(stylestack)
for child in elem:
if child.tag == 'li':
serializer.style(stylestack)
serializer.raw(' ' * indent * stylestack.get('tabwidth') + ' ' + (str(i)+')').ljust(cwidth))
i = i + 1
print_elem(stylestack,serializer,child,indent=indent+1)
serializer.end_entity()
elif elem.tag == 'pre':
serializer.start_block(stylestack)
serializer.pre(elem.text)
serializer.end_entity()
elif elem.tag == 'hr':
width = stylestack.get('width')
if stylestack.get('size') in ('double', 'double-width'):
width = width / 2
serializer.start_block(stylestack)
serializer.text('-'*width)
serializer.end_entity()
elif elem.tag == 'br':
serializer.linebreak()
elif elem.tag == 'img':
if 'src' in elem.attrib and 'data:' in elem.attrib['src']:
self.print_base64_image(bytes(elem.attrib['src'], 'utf-8'))
elif elem.tag == 'barcode' and 'encoding' in elem.attrib:
serializer.start_block(stylestack)
self.barcode(strclean(elem.text),elem.attrib['encoding'])
serializer.end_entity()
elif elem.tag == 'cut':
self.cut()
elif elem.tag == 'partialcut':
self.cut(mode='part')
elif elem.tag == 'cashdraw':
self.cashdraw(2)
self.cashdraw(5)
stylestack.pop()
try:
stylestack = StyleStack()
serializer = XmlSerializer(self)
root = ET.fromstring(xml.encode('utf-8'))
self._raw(stylestack.to_escpos())
print_elem(stylestack,serializer,root)
if 'open-cashdrawer' in root.attrib and root.attrib['open-cashdrawer'] == 'true':
self.cashdraw(2)
self.cashdraw(5)
if not 'cut' in root.attrib or root.attrib['cut'] == 'true' :
self.cut()
except Exception as e:
errmsg = str(e)+'\n'+'-'*48+'\n'+traceback.format_exc() + '-'*48+'\n'
self.text(errmsg)
self.cut()
raise e
def text(self,txt):
""" Print Utf8 encoded alpha-numeric text """
if not txt:
return
try:
txt = txt.decode('utf-8')
except:
try:
txt = txt.decode('utf-16')
except:
pass
self.extra_chars = 0
def encode_char(char):
"""
Encodes a single utf-8 character into a sequence of
esc-pos code page change instructions and character declarations
"""
char_utf8 = char.encode('utf-8')
encoded = ''
encoding = self.encoding # we reuse the last encoding to prevent code page switches at every character
encodings = {
# TODO use ordering to prevent useless switches
# TODO Support other encodings not natively supported by python ( Thai, Khazakh, Kanjis )
'cp437': TXT_ENC_PC437,
'cp850': TXT_ENC_PC850,
'cp852': TXT_ENC_PC852,
'cp857': TXT_ENC_PC857,
'cp858': TXT_ENC_PC858,
'cp860': TXT_ENC_PC860,
'cp863': TXT_ENC_PC863,
'cp865': TXT_ENC_PC865,
'cp1251': TXT_ENC_WPC1251, # win-1251 covers more cyrillic symbols than cp866
'cp866': TXT_ENC_PC866,
'cp862': TXT_ENC_PC862,
'cp720': TXT_ENC_PC720,
'cp936': TXT_ENC_PC936,
'iso8859_2': TXT_ENC_8859_2,
'iso8859_7': TXT_ENC_8859_7,
'iso8859_9': TXT_ENC_8859_9,
'cp1254' : TXT_ENC_WPC1254,
'cp1255' : TXT_ENC_WPC1255,
'cp1256' : TXT_ENC_WPC1256,
'cp1257' : TXT_ENC_WPC1257,
'cp1258' : TXT_ENC_WPC1258,
'katakana' : TXT_ENC_KATAKANA,
}
remaining = copy.copy(encodings)
if not encoding :
encoding = 'cp437'
while True: # Trying all encoding until one succeeds
try:
if encoding == 'katakana': # Japanese characters
if jcconv:
# try to convert japanese text to a half-katakanas
kata = jcconv.kata2half(jcconv.hira2kata(char_utf8))
if kata != char_utf8:
self.extra_chars += len(kata.decode('utf-8')) - 1
# the conversion may result in multiple characters
return encode_str(kata.decode('utf-8'))
else:
kata = char_utf8
if kata in TXT_ENC_KATAKANA_MAP:
encoded = TXT_ENC_KATAKANA_MAP[kata]
break
else:
raise ValueError()
else:
# First 127 symbols are covered by cp437.
# Extended range is covered by different encodings.
encoded = char.encode(encoding)
if ord(encoded) <= 127:
encoding = 'cp437'
break
except (UnicodeEncodeError, UnicodeWarning, TypeError, ValueError):
#the encoding failed, select another one and retry
if encoding in remaining:
del remaining[encoding]
if len(remaining) >= 1:
(encoding, _) = remaining.popitem()
else:
encoding = 'cp437'
encoded = b'\xb1' # could not encode, output error character
break
if encoding != self.encoding:
# if the encoding changed, remember it and prefix the character with
# the esc-pos encoding change sequence
self.encoding = encoding
encoded = bytes(encodings[encoding], 'utf-8') + encoded
return encoded
def encode_str(txt):
buffer = b''
for c in txt:
buffer += encode_char(c)
return buffer
txt = encode_str(txt)
# if the utf-8 -> codepage conversion inserted extra characters,
# remove double spaces to try to restore the original string length
# and prevent printing alignment issues
while self.extra_chars > 0:
dspace = txt.find(' ')
if dspace > 0:
txt = txt[:dspace] + txt[dspace+1:]
self.extra_chars -= 1
else:
break
self._raw(txt)
def set(self, align='left', font='a', type='normal', width=1, height=1):
""" Set text properties """
# Align
if align.upper() == "CENTER":
self._raw(TXT_ALIGN_CT)
elif align.upper() == "RIGHT":
self._raw(TXT_ALIGN_RT)
elif align.upper() == "LEFT":
self._raw(TXT_ALIGN_LT)
# Font
if font.upper() == "B":
self._raw(TXT_FONT_B)
else: # DEFAULT FONT: A
self._raw(TXT_FONT_A)
# Type
if type.upper() == "B":
self._raw(TXT_BOLD_ON)
self._raw(TXT_UNDERL_OFF)
elif type.upper() == "U":
self._raw(TXT_BOLD_OFF)
self._raw(TXT_UNDERL_ON)
elif type.upper() == "U2":
self._raw(TXT_BOLD_OFF)
self._raw(TXT_UNDERL2_ON)
elif type.upper() == "BU":
self._raw(TXT_BOLD_ON)
self._raw(TXT_UNDERL_ON)
elif type.upper() == "BU2":
self._raw(TXT_BOLD_ON)
self._raw(TXT_UNDERL2_ON)
elif type.upper == "NORMAL":
self._raw(TXT_BOLD_OFF)
self._raw(TXT_UNDERL_OFF)
# Width
if width == 2 and height != 2:
self._raw(TXT_NORMAL)
self._raw(TXT_2WIDTH)
elif height == 2 and width != 2:
self._raw(TXT_NORMAL)
self._raw(TXT_2HEIGHT)
elif height == 2 and width == 2:
self._raw(TXT_2WIDTH)
self._raw(TXT_2HEIGHT)
else: # DEFAULT SIZE: NORMAL
self._raw(TXT_NORMAL)
def cut(self, mode=''):
""" Cut paper """
# Fix the size between last line and cut
# TODO: handle this with a line feed
self._raw("\n\n\n\n\n\n")
if mode.upper() == "PART":
self._raw(PAPER_PART_CUT)
else: # DEFAULT MODE: FULL CUT
self._raw(PAPER_FULL_CUT)
def cashdraw(self, pin):
""" Send pulse to kick the cash drawer
For some reason, with some printers (ex: Epson TM-m30), the cash drawer
only opens 50% of the time if you just send the pulse. But if you read
the status afterwards, it opens all the time.
"""
if pin == 2:
self._raw(CD_KICK_2)
elif pin == 5:
self._raw(CD_KICK_5)
else:
raise CashDrawerError()
self.get_printer_status()
def hw(self, hw):
""" Hardware operations """
if hw.upper() == "INIT":
self._raw(HW_INIT)
elif hw.upper() == "SELECT":
self._raw(HW_SELECT)
elif hw.upper() == "RESET":
self._raw(HW_RESET)
else: # DEFAULT: DOES NOTHING
pass
def control(self, ctl):
""" Feed control sequences """
if ctl.upper() == "LF":
self._raw(CTL_LF)
elif ctl.upper() == "FF":
self._raw(CTL_FF)
elif ctl.upper() == "CR":
self._raw(CTL_CR)
elif ctl.upper() == "HT":
self._raw(CTL_HT)
elif ctl.upper() == "VT":
self._raw(CTL_VT)

View file

@ -0,0 +1,115 @@
""" ESC/POS Exceptions classes """
class Error(Exception):
""" Base class for ESC/POS errors """
def __init__(self, msg, status=None):
Exception.__init__(self)
self.msg = msg
self.resultcode = 1
if status is not None:
self.resultcode = status
def __str__(self):
return self.msg
# Result/Exit codes
# 0 = success
# 10 = No Barcode type defined
# 20 = Barcode size values are out of range
# 30 = Barcode text not supplied
# 40 = Image height is too large
# 50 = No string supplied to be printed
# 60 = Invalid pin to send Cash Drawer pulse
class BarcodeTypeError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 10
def __str__(self):
return "No Barcode type is defined"
class BarcodeSizeError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 20
def __str__(self):
return "Barcode size is out of range"
class BarcodeCodeError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 30
def __str__(self):
return "Code was not supplied"
class ImageSizeError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 40
def __str__(self):
return "Image height is longer than 255px and can't be printed"
class TextError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 50
def __str__(self):
return "Text string must be supplied to the text() method"
class CashDrawerError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 60
def __str__(self):
return "Valid pin must be set to send pulse"
class NoStatusError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 70
def __str__(self):
return "Impossible to get status from the printer: " + str(self.msg)
class TicketNotPrinted(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 80
def __str__(self):
return "A part of the ticket was not been printed: " + str(self.msg)
class NoDeviceError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 90
def __str__(self):
return str(self.msg)
class HandleDeviceError(Error):
def __init__(self, msg=""):
Error.__init__(self, msg)
self.msg = msg
self.resultcode = 100
def __str__(self):
return str(self.msg)

View file

@ -0,0 +1,227 @@
#!/usr/bin/python
from __future__ import print_function
import serial
import socket
import usb.core
import usb.util
from .escpos import *
from .constants import *
from .exceptions import *
from time import sleep
class Usb(Escpos):
""" Define USB printer """
def __init__(self, idVendor, idProduct, interface=0, in_ep=None, out_ep=None):
"""
@param idVendor : Vendor ID
@param idProduct : Product ID
@param interface : USB device interface
@param in_ep : Input end point
@param out_ep : Output end point
"""
self.errorText = "ERROR PRINTER\n\n\n\n\n\n"+PAPER_FULL_CUT
self.idVendor = idVendor
self.idProduct = idProduct
self.interface = interface
self.in_ep = in_ep
self.out_ep = out_ep
# pyusb dropped the 'interface' parameter from usb.Device.write() at 1.0.0b2
# https://github.com/pyusb/pyusb/commit/20cd8c1f79b24082ec999c022b56c3febedc0964#diff-b5a4f98a864952f0f55d569dd14695b7L293
if usb.version_info < (1, 0, 0) or (usb.version_info == (1, 0, 0) and usb.version_info[3] in ("a1", "a2", "a3", "b1")):
self.write_kwargs = dict(interface=self.interface)
else:
self.write_kwargs = {}
self.open()
def open(self):
""" Search device on USB tree and set is as escpos device """
self.device = usb.core.find(idVendor=self.idVendor, idProduct=self.idProduct)
if self.device is None:
raise NoDeviceError()
try:
if self.device.is_kernel_driver_active(self.interface):
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)
def close(self):
i = 0
while True:
try:
if not self.device.is_kernel_driver_active(self.interface):
usb.util.release_interface(self.device, self.interface)
self.device.attach_kernel_driver(self.interface)
usb.util.dispose_resources(self.device)
else:
self.device = None
return True
except usb.core.USBError as e:
i += 1
if i > 10:
return False
sleep(0.1)
def _raw(self, msg):
""" Print any command sent in raw format """
if len(msg) != self.device.write(self.out_ep, msg, timeout=5000, **self.write_kwargs):
self.device.write(self.out_ep, self.errorText, **self.write_kwargs)
raise TicketNotPrinted()
def __extract_status(self):
maxiterate = 0
rep = None
while rep == None:
maxiterate += 1
if maxiterate > 10000:
raise NoStatusError()
r = self.device.read(self.in_ep, 20, self.interface).tolist()
while len(r):
rep = r.pop()
return rep
def get_printer_status(self):
status = {
'printer': {},
'offline': {},
'error' : {},
'paper' : {},
}
self.device.write(self.out_ep, DLE_EOT_PRINTER, **self.write_kwargs)
printer = self.__extract_status()
self.device.write(self.out_ep, DLE_EOT_OFFLINE, **self.write_kwargs)
offline = self.__extract_status()
self.device.write(self.out_ep, DLE_EOT_ERROR, **self.write_kwargs)
error = self.__extract_status()
self.device.write(self.out_ep, DLE_EOT_PAPER, **self.write_kwargs)
paper = self.__extract_status()
status['printer']['status_code'] = printer
status['printer']['status_error'] = not ((printer & 147) == 18)
status['printer']['online'] = not bool(printer & 8)
status['printer']['recovery'] = bool(printer & 32)
status['printer']['paper_feed_on'] = bool(printer & 64)
status['printer']['drawer_pin_high'] = bool(printer & 4)
status['offline']['status_code'] = offline
status['offline']['status_error'] = not ((offline & 147) == 18)
status['offline']['cover_open'] = bool(offline & 4)
status['offline']['paper_feed_on'] = bool(offline & 8)
status['offline']['paper'] = not bool(offline & 32)
status['offline']['error'] = bool(offline & 64)
status['error']['status_code'] = error
status['error']['status_error'] = not ((error & 147) == 18)
status['error']['recoverable'] = bool(error & 4)
status['error']['autocutter'] = bool(error & 8)
status['error']['unrecoverable'] = bool(error & 32)
status['error']['auto_recoverable'] = not bool(error & 64)
status['paper']['status_code'] = paper
status['paper']['status_error'] = not ((paper & 147) == 18)
status['paper']['near_end'] = bool(paper & 12)
status['paper']['present'] = not bool(paper & 96)
return status
def __del__(self):
""" Release USB interface """
if self.device:
self.close()
self.device = None
class Serial(Escpos):
""" Define Serial printer """
def __init__(self, devfile="/dev/ttyS0", baudrate=9600, bytesize=8, timeout=1):
"""
@param devfile : Device file under dev filesystem
@param baudrate : Baud rate for serial transmission
@param bytesize : Serial buffer size
@param timeout : Read/Write timeout
"""
self.devfile = devfile
self.baudrate = baudrate
self.bytesize = bytesize
self.timeout = timeout
self.open()
def open(self):
""" Setup serial port and set is as escpos device """
self.device = serial.Serial(port=self.devfile, baudrate=self.baudrate, bytesize=self.bytesize, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=self.timeout, dsrdtr=True)
if self.device is not None:
print("Serial printer enabled")
else:
print("Unable to open serial printer on: %s" % self.devfile)
def _raw(self, msg):
""" Print any command sent in raw format """
self.device.write(msg)
def __del__(self):
""" Close Serial interface """
if self.device is not None:
self.device.close()
class Network(Escpos):
""" Define Network printer """
def __init__(self,host,port=9100):
"""
@param host : Printer's hostname or IP address
@param port : Port to write to
"""
self.host = host
self.port = port
self.open()
def open(self):
""" Open TCP socket and set it as escpos device """
self.device = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.device.connect((self.host, self.port))
if self.device is None:
print("Could not open socket for %s" % self.host)
def _raw(self, msg):
self.device.send(msg)
def __del__(self):
""" Close TCP connection """
self.device.close()

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-08-25 10:14+0000\n"
"Last-Translator: <>\n"
"Language-Team: Afrikaans (http://www.transifex.com/odoo/odoo-9/language/"
"af/)\n"
"Language: af\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotaal"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:35+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Arabic (http://www.transifex.com/odoo/odoo-9/language/ar/)\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "الخصومات"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "المجموع"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:35+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Bulgarian (http://www.transifex.com/odoo/odoo-9/language/"
"bg/)\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Отстъпки"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Междинна сума"

View file

@ -0,0 +1,41 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
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"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr " UKUPNO"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr " PROMJENA"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Popusti"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Sub-ukupno"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:35+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Catalan (http://www.transifex.com/odoo/odoo-9/language/ca/)\n"
"Language: ca\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descomptes"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Chris <krystof.reklamy13@gmail.com>, 2016
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-06-02 14:32+0000\n"
"Last-Translator: Chris <krystof.reklamy13@gmail.com>\n"
"Language-Team: Czech (http://www.transifex.com/odoo/odoo-9/language/cs/)\n"
"Language: cs\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "CELKEM"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Slevy"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Mezisoučet"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:35+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Danish (http://www.transifex.com/odoo/odoo-9/language/da/)\n"
"Language: da\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Fabian Liesch <fabian.liesch@gmail.com>, 2015
# Wolfgang Taferner, 2016
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-01-18 06:57+0000\n"
"Last-Translator: Wolfgang Taferner\n"
"Language-Team: German (http://www.transifex.com/odoo/odoo-9/language/de/)\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "GESAMTSUMME"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "WECHSELGELD"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Rabatte"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Zwischensumme"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Goutoudis Kostas <goutoudis@gmail.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-11-20 16:47+0000\n"
"Last-Translator: Goutoudis Kostas <goutoudis@gmail.com>\n"
"Language-Team: Greek (http://www.transifex.com/odoo/odoo-9/language/el/)\n"
"Language: el\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "ΣΥΝΟΛΟ"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Eκπτώσεις"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Μερικό σύνολο"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:36+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: English (United Kingdom) (http://www.transifex.com/odoo/"
"odoo-9/language/en_GB/)\n"
"Language: en_GB\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Carlos rodriguez <carlosalbertor436@gmail.com>, 2016
# Oihane Crucelaegui <oihanecruce@gmail.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-01-03 20:30+0000\n"
"Last-Translator: Carlos rodriguez <carlosalbertor436@gmail.com>\n"
"Language-Team: Spanish (http://www.transifex.com/odoo/odoo-9/language/es/)\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr " TOTAL"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "Cambio"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descuentos"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-08-25 10:14+0000\n"
"Last-Translator: <>\n"
"Language-Team: Spanish (Bolivia) (http://www.transifex.com/odoo/odoo-9/"
"language/es_BO/)\n"
"Language: es_BO\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descuentos"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:37+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Spanish (Chile) (http://www.transifex.com/odoo/odoo-9/"
"language/es_CL/)\n"
"Language: es_CL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Mateo Tibaquirá <nestormateo@gmail.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-10-24 06:51+0000\n"
"Last-Translator: Mateo Tibaquirá <nestormateo@gmail.com>\n"
"Language-Team: Spanish (Colombia) (http://www.transifex.com/odoo/odoo-9/"
"language/es_CO/)\n"
"Language: es_CO\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "TOTAL"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "CHANGE"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descuentos"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:37+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Spanish (Costa Rica) (http://www.transifex.com/odoo/odoo-9/"
"language/es_CR/)\n"
"Language: es_CR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Juliano Henriquez <juliano@consultoriahenca.com>, 2016
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-05-19 05:09+0000\n"
"Last-Translator: Juliano Henriquez <juliano@consultoriahenca.com>\n"
"Language-Team: Spanish (Dominican Republic) (http://www.transifex.com/odoo/"
"odoo-9/language/es_DO/)\n"
"Language: es_DO\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "TOTAL"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "CAMBIO"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descuentos"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Rick Hunter <rick_hunter_ec@yahoo.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-12-07 04:23+0000\n"
"Last-Translator: Rick Hunter <rick_hunter_ec@yahoo.com>\n"
"Language-Team: Spanish (Ecuador) (http://www.transifex.com/odoo/odoo-9/"
"language/es_EC/)\n"
"Language: es_EC\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "TOTAL"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "CAMBIO"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descuentos"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Carlos Eduardo Rodriguez Rossi <crodriguez@samemotion.com>, 2016
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-06-13 15:49+0000\n"
"Last-Translator: Carlos Eduardo Rodriguez Rossi <crodriguez@samemotion.com>\n"
"Language-Team: Spanish (Peru) (http://www.transifex.com/odoo/odoo-9/language/"
"es_PE/)\n"
"Language: es_PE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "TOTAL"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "VUELTO"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Descuentos"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:38+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Spanish (Paraguay) (http://www.transifex.com/odoo/odoo-9/"
"language/es_PY/)\n"
"Language: es_PY\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Sub-Total"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:38+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Spanish (Venezuela) (http://www.transifex.com/odoo/odoo-9/"
"language/es_VE/)\n"
"Language: es_VE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Subtotal"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:36+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Estonian (http://www.transifex.com/odoo/odoo-9/language/et/)\n"
"Language: et\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Soodustused"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Vahesumma"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-10-21 11:17+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Basque (http://www.transifex.com/odoo/odoo-9/language/eu/)\n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Deskontuak"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Batura partziala"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:37+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Persian (http://www.transifex.com/odoo/odoo-9/language/fa/)\n"
"Language: fa\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "جمع جزء"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Tuomo Aura <tuomo.aura@web-veistamo.fi>, 2016
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-04-19 15:31+0000\n"
"Last-Translator: Tuomo Aura <tuomo.aura@web-veistamo.fi>\n"
"Language-Team: Finnish (http://www.transifex.com/odoo/odoo-9/language/fi/)\n"
"Language: fi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "YHTEENSÄ"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "TAKAISIN"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Alennukset"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Välisumma"

View file

@ -0,0 +1,45 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Lucas Deliege <lud@odoo.com>, 2015
# Maxime Chambreuil <maxime.chambreuil@gmail.com>, 2015
# Sylvain GROS-DESORMEAUX <sylvain.grodes@gmail.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-10-01 23:16+0000\n"
"Last-Translator: Maxime Chambreuil <maxime.chambreuil@gmail.com>\n"
"Language-Team: French (http://www.transifex.com/odoo/odoo-9/language/fr/)\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "TOTAL"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "MONNAIE"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Remises"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Sous-total"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Ranjit Pillai <rpi@odoo.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:36+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Gujarati (http://www.transifex.com/odoo/odoo-9/language/gu/)\n"
"Language: gu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "કુલ"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "ફેરફાર"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "ડિસ્કાઉન્ટ"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr ""

View file

@ -0,0 +1,56 @@
# #-#-#-#-# he.po (Odoo 9.0) #-#-#-#-#
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Moshe Fam <pashute@gmail.com>, 2016
# yizhaq agronov <yizhaq@gmail.com>, 2016
# #-#-#-#-# he.po (Odoo 9.0) #-#-#-#-#
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# yizhaq agronov <yizhaq@gmail.com>, 2016
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2016-02-27 04:30+0000\n"
"Last-Translator: yizhaq agronov <yizhaq@gmail.com>\n"
"Language-Team: Hebrew (http://www.transifex.com/odoo/odoo-9/language/he/)\n"
"Language: he\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"#-#-#-#-# he.po (Odoo 9.0) #-#-#-#-#\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"#-#-#-#-# he.po (Odoo 9.0) #-#-#-#-#\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr "סה\"כ"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr "מזומן"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "הנחות"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "סיכום ביניים"

View file

@ -0,0 +1,42 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:36+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Hindi (http://www.transifex.com/odoo/odoo-9/language/hi/)\n"
"Language: hi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "उप कुल"

View file

@ -0,0 +1,43 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-09-07 21:35+0000\n"
"Last-Translator: Martin Trigaux\n"
"Language-Team: Croatian (http://www.transifex.com/odoo/odoo-9/language/hr/)\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr ""
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Popusti"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Podzbroj"

View file

@ -0,0 +1,44 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hw_escpos
#
# Translators:
# Kris Krnacs, 2015
msgid ""
msgstr ""
"Project-Id-Version: Odoo 9.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-09-07 14:39+0000\n"
"PO-Revision-Date: 2015-11-11 22:25+0000\n"
"Last-Translator: Kris Krnacs\n"
"Language-Team: Hungarian (http://www.transifex.com/odoo/odoo-9/language/"
"hu/)\n"
"Language: hu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:302
#, python-format
msgid " TOTAL"
msgstr " ÖSSZESEN"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:312
#, python-format
msgid " CHANGE"
msgstr " VISSZAJÁRÓ"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:318
#, python-format
msgid "Discounts"
msgstr "Árengedmények"
#. module: hw_escpos
#: code:addons/hw_escpos/controllers/main.py:293
#, python-format
msgid "Subtotal"
msgstr "Nettó érték"

Some files were not shown because too many files have changed in this diff Show more