mirror of
https://github.com/bringout/oca-ocb-hw.git
synced 2026-04-18 04:01:59 +02:00
Initial commit: Hw packages
This commit is contained in:
commit
a9d00500da
161 changed files with 10506 additions and 0 deletions
9
README.md
Normal file
9
README.md
Normal 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
|
||||
53
odoo-bringout-oca-ocb-hw_drivers/README.md
Normal file
53
odoo-bringout-oca-ocb-hw_drivers/README.md
Normal 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
|
||||
32
odoo-bringout-oca-ocb-hw_drivers/doc/ARCHITECTURE.md
Normal file
32
odoo-bringout-oca-ocb-hw_drivers/doc/ARCHITECTURE.md
Normal 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.
|
||||
3
odoo-bringout-oca-ocb-hw_drivers/doc/CONFIGURATION.md
Normal file
3
odoo-bringout-oca-ocb-hw_drivers/doc/CONFIGURATION.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Configuration
|
||||
|
||||
Refer to Odoo settings for hw_drivers. Configure related models, access rights, and options as needed.
|
||||
17
odoo-bringout-oca-ocb-hw_drivers/doc/CONTROLLERS.md
Normal file
17
odoo-bringout-oca-ocb-hw_drivers/doc/CONTROLLERS.md
Normal 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.
|
||||
3
odoo-bringout-oca-ocb-hw_drivers/doc/DEPENDENCIES.md
Normal file
3
odoo-bringout-oca-ocb-hw_drivers/doc/DEPENDENCIES.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Dependencies
|
||||
|
||||
No explicit module dependencies declared.
|
||||
4
odoo-bringout-oca-ocb-hw_drivers/doc/FAQ.md
Normal file
4
odoo-bringout-oca-ocb-hw_drivers/doc/FAQ.md
Normal 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.
|
||||
7
odoo-bringout-oca-ocb-hw_drivers/doc/INSTALL.md
Normal file
7
odoo-bringout-oca-ocb-hw_drivers/doc/INSTALL.md
Normal 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"
|
||||
```
|
||||
11
odoo-bringout-oca-ocb-hw_drivers/doc/MODELS.md
Normal file
11
odoo-bringout-oca-ocb-hw_drivers/doc/MODELS.md
Normal 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.
|
||||
6
odoo-bringout-oca-ocb-hw_drivers/doc/OVERVIEW.md
Normal file
6
odoo-bringout-oca-ocb-hw_drivers/doc/OVERVIEW.md
Normal 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
|
||||
3
odoo-bringout-oca-ocb-hw_drivers/doc/REPORTS.md
Normal file
3
odoo-bringout-oca-ocb-hw_drivers/doc/REPORTS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Reports
|
||||
|
||||
This module does not define custom reports.
|
||||
8
odoo-bringout-oca-ocb-hw_drivers/doc/SECURITY.md
Normal file
8
odoo-bringout-oca-ocb-hw_drivers/doc/SECURITY.md
Normal 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
|
||||
5
odoo-bringout-oca-ocb-hw_drivers/doc/TROUBLESHOOTING.md
Normal file
5
odoo-bringout-oca-ocb-hw_drivers/doc/TROUBLESHOOTING.md
Normal 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.
|
||||
7
odoo-bringout-oca-ocb-hw_drivers/doc/USAGE.md
Normal file
7
odoo-bringout-oca-ocb-hw_drivers/doc/USAGE.md
Normal 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
|
||||
```
|
||||
3
odoo-bringout-oca-ocb-hw_drivers/doc/WIZARDS.md
Normal file
3
odoo-bringout-oca-ocb-hw_drivers/doc/WIZARDS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Wizards
|
||||
|
||||
This module does not include UI wizards.
|
||||
12
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__init__.py
Normal file
12
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__init__.py
Normal 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
|
||||
22
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__manifest__.py
Normal file
22
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__manifest__.py
Normal 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',
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
85
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/driver.py
Executable file
85
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/driver.py
Executable 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
|
||||
|
|
@ -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
|
||||
75
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/driver.py
Normal file
75
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/driver.py
Normal 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
|
||||
56
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/event_manager.py
Normal file
56
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/event_manager.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
10
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/http.py
Normal file
10
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/http.py
Normal 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
|
||||
71
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/interface.py
Normal file
71
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/interface.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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'})
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
148
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/main.py
Normal file
148
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/main.py
Normal 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()
|
||||
164
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/server_logger.py
Normal file
164
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/server_logger.py
Normal 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)
|
||||
|
|
@ -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();
|
||||
});
|
||||
501
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/tools/helpers.py
Normal file
501
odoo-bringout-oca-ocb-hw_drivers/hw_drivers/tools/helpers.py
Normal 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)
|
||||
|
|
@ -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>
|
||||
41
odoo-bringout-oca-ocb-hw_drivers/pyproject.toml
Normal file
41
odoo-bringout-oca-ocb-hw_drivers/pyproject.toml
Normal 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",
|
||||
]
|
||||
52
odoo-bringout-oca-ocb-hw_escpos/README.md
Normal file
52
odoo-bringout-oca-ocb-hw_escpos/README.md
Normal 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
|
||||
32
odoo-bringout-oca-ocb-hw_escpos/doc/ARCHITECTURE.md
Normal file
32
odoo-bringout-oca-ocb-hw_escpos/doc/ARCHITECTURE.md
Normal 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.
|
||||
3
odoo-bringout-oca-ocb-hw_escpos/doc/CONFIGURATION.md
Normal file
3
odoo-bringout-oca-ocb-hw_escpos/doc/CONFIGURATION.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Configuration
|
||||
|
||||
Refer to Odoo settings for hw_escpos. Configure related models, access rights, and options as needed.
|
||||
17
odoo-bringout-oca-ocb-hw_escpos/doc/CONTROLLERS.md
Normal file
17
odoo-bringout-oca-ocb-hw_escpos/doc/CONTROLLERS.md
Normal 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.
|
||||
3
odoo-bringout-oca-ocb-hw_escpos/doc/DEPENDENCIES.md
Normal file
3
odoo-bringout-oca-ocb-hw_escpos/doc/DEPENDENCIES.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Dependencies
|
||||
|
||||
No explicit module dependencies declared.
|
||||
4
odoo-bringout-oca-ocb-hw_escpos/doc/FAQ.md
Normal file
4
odoo-bringout-oca-ocb-hw_escpos/doc/FAQ.md
Normal 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.
|
||||
7
odoo-bringout-oca-ocb-hw_escpos/doc/INSTALL.md
Normal file
7
odoo-bringout-oca-ocb-hw_escpos/doc/INSTALL.md
Normal 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"
|
||||
```
|
||||
11
odoo-bringout-oca-ocb-hw_escpos/doc/MODELS.md
Normal file
11
odoo-bringout-oca-ocb-hw_escpos/doc/MODELS.md
Normal 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.
|
||||
6
odoo-bringout-oca-ocb-hw_escpos/doc/OVERVIEW.md
Normal file
6
odoo-bringout-oca-ocb-hw_escpos/doc/OVERVIEW.md
Normal 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
|
||||
3
odoo-bringout-oca-ocb-hw_escpos/doc/REPORTS.md
Normal file
3
odoo-bringout-oca-ocb-hw_escpos/doc/REPORTS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Reports
|
||||
|
||||
This module does not define custom reports.
|
||||
8
odoo-bringout-oca-ocb-hw_escpos/doc/SECURITY.md
Normal file
8
odoo-bringout-oca-ocb-hw_escpos/doc/SECURITY.md
Normal 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
|
||||
5
odoo-bringout-oca-ocb-hw_escpos/doc/TROUBLESHOOTING.md
Normal file
5
odoo-bringout-oca-ocb-hw_escpos/doc/TROUBLESHOOTING.md
Normal 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.
|
||||
7
odoo-bringout-oca-ocb-hw_escpos/doc/USAGE.md
Normal file
7
odoo-bringout-oca-ocb-hw_escpos/doc/USAGE.md
Normal 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
|
||||
```
|
||||
3
odoo-bringout-oca-ocb-hw_escpos/doc/WIZARDS.md
Normal file
3
odoo-bringout-oca-ocb-hw_escpos/doc/WIZARDS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Wizards
|
||||
|
||||
This module does not include UI wizards.
|
||||
4
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/README.md
Normal file
4
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
The escpos directory contains the MIT licensed pyxmlescpos lib taken from
|
||||
|
||||
https://github.com/fvdsn/py-xml-escpos
|
||||
|
||||
5
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__init__.py
Normal file
5
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__init__.py
Normal 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
|
||||
24
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__manifest__.py
Normal file
24
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__manifest__.py
Normal 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',
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
344
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/main.py
Normal file
344
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/main.py
Normal 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)
|
||||
21
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/LICENSE.txt
Normal file
21
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/LICENSE.txt
Normal 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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
__all__ = ["constants","escpos","exceptions","printer"]
|
||||
190
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/constants.py
Normal file
190
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/constants.py
Normal 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
|
||||
935
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/escpos.py
Normal file
935
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/escpos.py
Normal 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)
|
||||
115
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/exceptions.py
Normal file
115
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/exceptions.py
Normal 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)
|
||||
227
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/printer.py
Normal file
227
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/printer.py
Normal 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()
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/af.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/af.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ar.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ar.po
Normal 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 "المجموع"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bg.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bg.po
Normal 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 "Междинна сума"
|
||||
41
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bs.po
Normal file
41
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bs.po
Normal 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"
|
||||
|
||||
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ca.po
Normal file
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ca.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/cs.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/cs.po
Normal 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"
|
||||
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/da.po
Normal file
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/da.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/de.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/de.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/el.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/el.po
Normal 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 "Μερικό σύνολο"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/en_GB.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/en_GB.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_BO.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_BO.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CL.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CL.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CO.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CO.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CR.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CR.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_DO.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_DO.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_EC.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_EC.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PE.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PE.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PY.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PY.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_VE.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_VE.po
Normal 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"
|
||||
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/et.po
Normal file
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/et.po
Normal 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"
|
||||
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/eu.po
Normal file
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/eu.po
Normal 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"
|
||||
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fa.po
Normal file
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fa.po
Normal 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 "جمع جزء"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fi.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fi.po
Normal 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"
|
||||
45
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fr.po
Normal file
45
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fr.po
Normal 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"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/gu.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/gu.po
Normal 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 ""
|
||||
56
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/he.po
Normal file
56
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/he.po
Normal 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 "סיכום ביניים"
|
||||
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hi.po
Normal file
42
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hi.po
Normal 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 "उप कुल"
|
||||
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hr.po
Normal file
43
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hr.po
Normal 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"
|
||||
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hu.po
Normal file
44
odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hu.po
Normal 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
Loading…
Add table
Add a link
Reference in a new issue