Files
hotel/modules/casposbridge/classes/CasposApiClient.php
DanielS b3b79960e7 casposbridge: verified against a real CASPOS 4.12.3
- send User-Agent "OrderApp/1.4.0 (linux) QloApps-CasposBridge/1.0" and
  version 1.4.0: CASPOS classifies non-OrderApp clients as "Orderman",
  which cannot be activated in the Order-App device manager
- fixed prices for open-price articles: ARTICLE@12.50 / @12,50 (cents)
- live test (docker/test-casposbridge-live.php) booked one night on
  table 101: 1 x 301 @ 145.00 + 2 x 101 @ 12.50, bookedAmount 17000,
  lines committed -> enteredPrice is in cents
- README: device activation, waiter key, per-area table number ranges
  ("Ungültige Tischnummer"), results
- docker/reset-admin-password.php: set a back-office password from the
  CLI (asked interactively, not echoed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:53:20 +01:00

277 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Client for the CASPOS OrderApp API (http(s)://host:port/api/v2), modelled on the
* CASPOS OrderApp (lib/api/api.dart): registerClient -> waiter login -> tables/order lines.
* Session state is a cookie jar kept in memory for one run.
*/
class CasposApiException extends Exception
{
/** @var int HTTP status (0 = no connection) */
public $httpCode;
/** @var int|null CASPOS extendedErrorCode (1001 client not registered, 1002 waiter login required) */
public $extendedCode;
public function __construct($message, $httpCode = 0, $extendedCode = null)
{
parent::__construct($message, (int)$httpCode);
$this->httpCode = (int)$httpCode;
$this->extendedCode = $extendedCode;
}
/** Errors where a later retry can succeed (network, POS busy/offline). */
public function isTemporary()
{
return $this->httpCode === 0 || $this->httpCode >= 500 || $this->httpCode === 408;
}
}
class CasposApiClient
{
const TIMEOUT = 15;
/**
* CASPOS decides the device type from the User-Agent. Anything not starting with
* "OrderApp/" is listed as "Orderman" in the Order-App device manager, where it cannot be
* activated. Version must satisfy the POS's minimum OrderApp version.
*/
const APP_VERSION = '1.4.0';
const USER_AGENT = 'OrderApp/1.4.0 (linux) QloApps-CasposBridge/1.0';
protected $baseUrl;
protected $fingerprint;
protected $clientId;
protected $clientName;
protected $waiterId;
protected $waiterPassword;
protected $cookies = array();
protected $fingerprintChecked = false;
protected $loggedIn = false;
/**
* @param array $cfg host, port, fingerprint (SHA1 "AA:BB:.." -> https), client_id, client_name,
* waiter_id, waiter_password
*/
public function __construct(array $cfg)
{
$host = trim((string)$cfg['host']);
$port = (int)$cfg['port'];
if ($host === '' || $port <= 0) {
throw new CasposApiException('Kasse: Adresse oder Port fehlt in den Einstellungen.');
}
$this->fingerprint = self::normalizeFingerprint(isset($cfg['fingerprint']) ? $cfg['fingerprint'] : '');
$scheme = $this->fingerprint ? 'https' : 'http';
$this->baseUrl = $scheme.'://'.$host.':'.$port.'/api/v2/';
$this->clientId = (string)$cfg['client_id'];
$this->clientName = (string)$cfg['client_name'];
$this->waiterId = (string)$cfg['waiter_id'];
$this->waiterPassword = (string)$cfg['waiter_password'];
}
public static function normalizeFingerprint($fp)
{
$hex = strtoupper(preg_replace('/[^0-9a-fA-F]/', '', (string)$fp));
return strlen($hex) === 40 ? implode(':', str_split($hex, 2)) : '';
}
/** registerClient + waiter login; safe to call repeatedly. */
public function startSession($version = self::APP_VERSION)
{
if ($this->loggedIn) {
return;
}
$this->request('POST', 'actions/init/registerClient', array(
'version' => $version,
'id' => $this->clientId,
'clientName' => $this->clientName,
'printerPath' => null,
));
$this->request('POST', 'actions/auth/login', array('id' => $this->waiterId, 'password' => $this->waiterPassword));
$this->loggedIn = true;
}
public function endSession()
{
if ($this->loggedIn) {
try {
$this->request('POST', 'actions/auth/logout');
} catch (CasposApiException $e) {
// logout failures are irrelevant
}
$this->loggedIn = false;
}
}
public function getServiceAreas()
{
return (array)$this->request('GET', 'data/serviceAreas');
}
public function getArticles()
{
return (array)$this->request('GET', 'data/articles');
}
/** @return array table incl. 'id' and 'subTables' */
public function openTableByName($name, $serviceAreaId)
{
$table = $this->request('POST', 'actions/tables/openByName/'.rawurlencode($name).'?'.http_build_query(array(
'serviceAreaId' => $serviceAreaId,
'prePayment' => 'false',
'forMove' => 'false',
)));
if (empty($table['id'])) {
throw new CasposApiException('Kasse: Tisch "'.$name.'" konnte nicht geöffnet werden (keine Tisch-ID).');
}
return $table;
}
/**
* @param int|null $enteredPrice price in cents for open-price articles (unit as sent by the OrderApp)
*/
public function addOrderLine($subTableId, $articleId, $quantity, $enteredPrice = null, $enteredText = null)
{
$body = array('articleId' => (string)$articleId, 'quantity' => (int)$quantity, 'modifiers' => array());
if ($enteredPrice !== null) {
$body['enteredPrice'] = (int)$enteredPrice;
}
if ($enteredText !== null && $enteredText !== '') {
$body['enteredText'] = (string)$enteredText;
}
return $this->request('POST', 'actions/orderLines/add/'.rawurlencode($subTableId), $body);
}
public function finalizeOrder($tableId)
{
$this->request('POST', 'actions/tables/finalizeOrder/'.rawurlencode($tableId));
}
public function cancelOrder($tableId)
{
$this->request('POST', 'actions/tables/cancelOrder/'.rawurlencode($tableId));
}
/**
* @return array|null decoded JSON (null/empty array for 204)
* @throws CasposApiException
*/
protected function request($method, $path, $body = null)
{
if ($this->fingerprint && !$this->fingerprintChecked) {
$this->checkFingerprint();
}
$headers = array('Accept: application/json');
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
if ($this->cookies && strpos($path, 'registerClient') === false) {
$pairs = array();
foreach ($this->cookies as $k => $v) {
$pairs[] = $k.'='.$v;
}
$headers[] = 'Cookie: '.implode('; ', $pairs);
}
$ch = curl_init($this->baseUrl.$path);
$opts = array(
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
CURLOPT_TIMEOUT => self::TIMEOUT,
CURLOPT_USERAGENT => self::USER_AGENT,
);
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
} elseif ($method === 'POST') {
$opts[CURLOPT_POSTFIELDS] = '';
}
if ($this->fingerprint) {
// the POS uses a self-signed certificate; trust is established by the pinned fingerprint
$opts[CURLOPT_SSL_VERIFYPEER] = false;
$opts[CURLOPT_SSL_VERIFYHOST] = 0;
}
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new CasposApiException('Kasse nicht erreichbar ('.$this->baseUrl.'): '.$err, 0);
}
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$this->storeCookies(substr($raw, 0, $headerSize));
$responseBody = substr($raw, $headerSize);
$data = $responseBody === '' ? array() : json_decode($responseBody, true);
if ($code >= 200 && $code < 300) {
return $data;
}
throw $this->buildError($code, is_array($data) ? $data : array(), $path);
}
protected function buildError($code, array $data, $path)
{
$ext = isset($data['extendedErrorCode']) ? (int)$data['extendedErrorCode'] : null;
$detail = isset($data['errorMsg']) ? ' ('.$data['errorMsg'].')' : '';
if ($code === 403 && strpos($path, 'registerClient') !== false) {
$msg = 'Kasse: Dieses Gerät ist in der OrderApp-Verwaltung der Kasse nicht freigeschaltet.';
} elseif ($code === 401 && $ext === 1001) {
$msg = 'Kasse: Client nicht registriert.';
$this->loggedIn = false;
} elseif ($code === 401 && $ext === 1002) {
$msg = 'Kasse: Kellner-Anmeldung erforderlich.';
$this->loggedIn = false;
} elseif ($code === 401) {
$msg = 'Kasse: Anmeldung abgelehnt (Kellner-ID oder Passwort prüfen).';
$this->loggedIn = false;
} elseif ($code === 404) {
$msg = 'Kasse: Adresse nicht gefunden – API-Port und Version (api/v2) prüfen.';
} else {
$msg = 'Kasse: Fehler HTTP '.$code;
}
return new CasposApiException($msg.$detail, $code, $ext);
}
protected function storeCookies($rawHeaders)
{
if (preg_match_all('/^Set-Cookie:\s*([^=;\s]+)=([^;\r\n]*)/mi', $rawHeaders, $m, PREG_SET_ORDER)) {
foreach ($m as $c) {
$this->cookies[$c[1]] = $c[2];
}
}
}
/** Compare the server certificate's SHA1 fingerprint before any credentials are sent. */
protected function checkFingerprint()
{
$ch = curl_init($this->baseUrl.'hostStatus');
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => true,
CURLOPT_CERTINFO => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
CURLOPT_TIMEOUT => self::TIMEOUT,
));
$ok = curl_exec($ch);
$info = curl_getinfo($ch, CURLINFO_CERTINFO);
$err = curl_error($ch);
curl_close($ch);
if ($ok === false && empty($info)) {
throw new CasposApiException('Kasse nicht erreichbar: '.$err, 0);
}
$pem = isset($info[0]['Cert']) ? $info[0]['Cert'] : '';
$der = base64_decode(preg_replace('/-----(BEGIN|END) CERTIFICATE-----|\s+/', '', $pem));
$actual = $der ? implode(':', str_split(strtoupper(sha1($der)), 2)) : '';
if ($actual !== $this->fingerprint) {
throw new CasposApiException('Kasse: Zertifikat passt nicht zum hinterlegten Fingerabdruck (erhalten: '.($actual ?: 'keins').').', 495);
}
$this->fingerprintChecked = true;
}
}