Displayformatet
Displayerna är speglar. All logik - vad en tangent betyder, vad som står
på kvittot, vilken dialog som väntar - finns i kassan (Till, driven av
web_till). En display får ett färdigt tillstånd och ritar det; den
skickar tillbaka händelser (en tangent trycktes ned, en rad
klickades) och väntar på nästa tillstånd. Den räknar aldrig själv, och
den behöver inte veta varför ett tillstånd ser ut som det gör.
Samma tillstånd bär båda displayerna: operatörsdisplayens kvittovy och
kunddisplayens två rader ligger i samma meddelande. De byggs ur samma
Till i samma ögonblick, så de kan inte komma ur takt.
Transport
web_till har två portar:
| port (standard) | flagga | protokoll | används av |
|---|---|---|---|
127.0.0.1:8090 | --http | HTTP | statiska filer, GET /state, POST /key_press m.fl. |
127.0.0.1:8091 | --ws | WebSocket | operatörsklienten web_client/app.js |
GET /server_info.json svarar med den faktiska WebSocket-porten och
kassans decimalinställning, så att en klient inte behöver gissa porten:
{"ws_port": 8091, "decimal_places": 0}
Kassan annonseras också på mDNS som _rgo-kassa._tcp.local. med
ws_port och version i TXT-posten.
WebSocket är huvudvägen. Varje ansluten flik registreras, och efter varje tillståndsändrande kommando sänds det nya tillståndet till alla anslutna flikar, inte bara den som skickade kommandot - en streckkod som läses in via HTTP syns därmed i alla öppna fönster. Vid anslutning skickas aktuellt tillstånd direkt till den nya fliken.
HTTP-pollning finns för klienter som inte kan hålla en WebSocket:
ES5-klienten web_client/app.es5.js (gamla Android-WebViews på V-R100
och CHD6800) pollar GET /state var 2:a sekund och skickar tangenter
med POST /key_press, och kundfönstret web_client_customer/customer.js
pollar GET /state en gång i sekunden och skickar aldrig något.
Kuvertet
Varje WebSocket-meddelande, i båda riktningar, är ett kuvert:
{"destination": "operator_display", "type": "state", "payload": { ... }}
| fält | betydelse |
|---|---|
destination | Alltid "operator_display" i dag. Finns för att en ny mottagare ska kunna bli ett nytt värde i stället för ett nytt protokoll. Servern läser inte fältet i inkommande meddelanden; klienten app.js kastar meddelanden med annan destination. |
type | Meddelandets slag, se nedan. |
payload | Innehållet. |
Från kassan kommer två typer: state och terminal_display. Från
klienten tas tre emot: key_press, select_tab och select_line. Ett
meddelande av annan typ, eller som inte är giltig JSON, ignoreras.
GET /state och POST /key_press svarar med exakt samma
state-kuvert som WebSocket-vägen skickar.
Tillståndet (type: "state")
Byggs av snapshot_to_json (web_till/json.rs) ur
Till::display_snapshot och Till::customer_display_snapshot.
| fält | typ | betydelse |
|---|---|---|
buffer | sträng | Det som är inslaget på sifferbordet. "0" när inget är inslaget. Med decimaler påslagna kan den innehålla ett , ("35,5"). Det är inslagna tecken, inte ett tolkat belopp. |
mode | sträng | Vad kassan väntar på: "price", "awaiting_confirmation", "registering_product" eller "awaiting_credit_checksum". |
prompt | sträng eller null | Färdig svensk text att visa. Satt för awaiting_confirmation och awaiting_credit_checksum, annars null. |
barcode | sträng eller null | Den lästa streckkoden när mode är registering_product, annars null. |
terminal_display | sträng eller null | Kortterminalens aktuella text (Nets), eller null. Se nedan. |
cart_lines | lista | Det pågående kvittots rader: {"group_name": sträng, "amount_minor": heltal}. group_name är varugruppens namn, eller artikelns namn vid streckkodsförsäljning. Belopp i öre. |
subtotal_minor | heltal | Kvittots summa i öre. |
vat_minor | heltal | Kvittots moms i öre, summerad över momssatserna. |
receipt_display_lines | lista av DisplayLine | Kvittot som rader och kolumner, se nedan. Tom lista när inget är inslaget. |
customer_display | objekt | Kunddisplayens två rader: {"row1": [DisplayColumn], "row2": [DisplayColumn]}. |
selected_line_event_id | heltal eller null | Raden som är markerad (dess row_id), så att alla flikar markerar samma rad. |
active_tab_index | heltal | Vilken flik i varupanelen som är vald. |
last_error | sträng eller null | Ett engångsmeddelande som hör till just den händelse som gav svaret. Se nedan. |
Ordningen på nycklarna i JSON är inte garanterad.
mode och prompt
mode slår ihop flera interna väntelägen till ett enda
awaiting_confirmation, eftersom en display bara behöver visa en
Ja/Nej-fråga, inte veta vilken. Kassan väljer i samma ordning som den
själv tolkar nästa Ja/Nej, så frågan som visas är alltid den som
besvaras:
| läge | prompt |
|---|---|
| Z-rapportens utskrift misslyckades | "Z-rapport: försök skriva ut igen? (Y/N)" |
| kontrollenheten svarade inte | "Kontrollenheten svarar inte - försök igen? (Ja/Nej)" |
| journalen kunde inte skrivas | "Försök igen? (Y/N)" |
| kassans interna utskrift väntar på svar | "Skriv kvitto? (Y/N)" |
registering_product betyder att en streckkod lästes som inte finns i
varuregistret: operatören slår ett pris och en varugrupp, och varan
läggs upp och säljs i samma tryck. awaiting_credit_checksum betyder
att ett tillgodobelopp har slagits in och kassan väntar på
kontrollsumman från tillgodokvittot; prompt innehåller då texten
"Tillgodo <kr>,<öre> - ange checksumman, tryck TILLGODO".
receipt_display_lines och DisplayLine
Raderna är samma ReceiptLayout som ett kvitto skrivs ut från (se
Utskriftsformatet), omräknade till kolumner med
position. Under ett pågående köp används den förenklade layouten
build_receipt_layout_in_progress: en rad per inslagen rad plus en
SUBTOTAL-rad. Efter betalningen, så länge kvittot ligger kvar i
“slussen” i väntan på att skrivas ut eller avslutas, visas i stället
hela det betalda kvittot (build_receipt_layout, utan logga) - med
betalsätt, växel och kontrollkod, exakt som på papperet.
| fält | typ | betydelse |
|---|---|---|
columns | lista av DisplayColumn | Radens kolumner. |
row_id | heltal eller null | event_id för radens LINE_ADDED i journalen. Sätts bara på varurader i ett pågående köp; null för SUBTOTAL och för alla rader i ett betalt kvitto. Det är värdet klienten skickar i select_line. |
style | sträng | Radens stil med samma namn som på kvittot: normal, header, bold_total, dbl_height, dbl_width, dbl_size. Kolumnstilar (column_styles) följer inte med. |
En bildrad blir en kolumn med texten "[BILD: <source>]" och en QR-rad
blir "[QR]"; inga sådana rader förekommer i displaylayouterna i dag.
DisplayColumn
{"text": "35:00", "percent": 85.0, "align": "right"}
| fält | typ | betydelse |
|---|---|---|
text | sträng | Det som ska stå. Belopp är formaterade som på kvittot ("35:00"). |
percent | tal | Kolumnens position i procent av radens bredd. |
align | "left", "right", "center" | Justering. |
percent betyder olika saker beroende på align, och det är den
vanligaste källan till fel i en egen klient:
align | percent är | kolumnens högerkant |
|---|---|---|
left | vänsterkanten | - |
right | kolumnens början; texten slutar där nästa kolumn börjar, eller vid 100 % för sista kolumnen | nästa kolumns percent, annars 100 |
center | mittpunkten - en centrerad rad över hela bredden har percent 50 | - |
För en flerkolumnsrad kommer percent direkt från kvittoradens
tabs_percent, och align från alignments. För en enkolumnsrad är
percent 0 för vänster och höger och 50 för centrerat. Kolumnerna ska
placeras mot displayens faktiska bredd (operatörsklienten gör det med
absolut positionerade element i procent), inte paddas till ett antal
tecken - skärmens ruta är bredare än kvittots 35 tecken.
Kunddisplayen: row1 och row2
Kunddisplayen är tänkt som en fysisk display med 2 rader om 20 tecken, utan grafik. Den visar bara den senaste händelsen av tre slag, inte hela kvittot:
| senaste händelse | row1 | row2 |
|---|---|---|
| ingenting ännu (sedan kassan startade) | [] | [] |
| en rad slogs in | varans namn, vänster, percent 0 | antal (vänster, percent 0) och radens belopp (höger, percent 50) |
SUBTOTAL trycktes | "Att betala", vänster | återstående belopp, höger |
| betalningen klar | "Växel", vänster | växeln, höger ("0:00" för allt utom kontant med angivet belopp) |
Kunddisplayens tillstånd journalförs inte och återskapas inte efter en omstart; det är en markör för “vad visas nu”, inget annat.
Begränsningarna:
- 20 tecken per rad. Kassan kortar inte texten. Både webbfönstret
(
customer.js,layoutRow) och den fysiska displayen i Casio-appen (CaiosCustomerDisplay.layoutRow) lägger ut kolumnerna på 20 teckenpositioner och klipper varje kolumn vid sin plats - ett långt varunamn blir alltså avklippt. - Ingen grafik och inga stilar.
DisplayColumnhar ingen stil. - I
row2för en inslagen rad börjar antalet vidpercent0 och beloppet vidpercent50. Klienterna räknar en kolumns slut som nästa kolumnspercent, så antalet får radens vänstra halva (10 tecken) och beloppet den högra, högerställt mot kanten. Bådecustomer.jsoch Casio-appenslayoutRowlägger alltså ut raden likadant. Ett belopp längre än 10 tecken klipps. - Antalet i
row2är det verkliga antalet (multiplikatorn som slogs in), trots att en kommentar i koden fortfarande säger att det alltid är 1.
Casio-appens skrivartjänst (android-casio-receipt-print) tar emot
exakt den här formen på POST /display ({"row1": [...], "row2": [...]}, eller två strängar), men web_till skickar inte dit i dag.
terminal_display och meddelandet terminal_display
Under ett kortköp mot Nets visar kortterminalen uppmaningar (“Sätt i
kort”, “Ange PIN”). De skickas inte genom Till, som står blockerad i
kortbetalningen hela tiden, utan direkt från terminalens läsartråd som
ett eget meddelande till alla WebSocket-klienter:
{"destination": "operator_display", "type": "terminal_display", "payload": {"text": "Ange PIN"}}
"text": null betyder att köpet är slut och texten ska bort. Samma text
läggs också i fältet terminal_display i state, så att en pollande
klient ser den. Eftersom state byggs av den blockerade kassatråden kan
det fältet vara inaktuellt null under köpet; app.js låter därför
state bara fylla i en terminaltext, aldrig ta bort den - bara
terminal_display-meddelandets null tar bort den. Worldline-terminalen
skickar inga sådana texter.
last_error
last_error hör till den enskilda händelsen, inte till kassans
tillstånd. Det sätts bara i svaret på händelsen som orsakade det och är
null i nästa svar. GET /state har alltid null.
För key_press bär det tre slags meddelanden, i den här
prioritetsordningen:
- Ett fel från kassan själv (
Till::handle), när tangenten inte kunde utföras - till exempel"Inget att betala - registrera minst en rad först", ett nekat kortköp eller en okänd varugrupp. Felet loggas också. - Kontrollenhetens fel vid en betalning.
- Utfallet av SKVFS-exporten (
"SKVFS-export skapad: <sökväg>"eller"SKVFS-export misslyckades: ...").
Texterna är skrivna för kassören och är på svenska. Kortterminalens
och bokföringens fel vid en rapport når inte last_error; de loggas
bara. För select_tab, select_line och device_event blir varje fel
last_error.
Meddelanden från klienten
key_press
{"destination": "operator_display", "type": "key_press",
"payload": {"scancode": 101, "event_type": "make", "caps_lock": false}}
{"destination": "operator_display", "type": "key_press",
"payload": {"scancode": 101, "event_type": "break", "caps_lock": false}}
| fält | typ | betydelse |
|---|---|---|
scancode | heltal | Tangentens kod. Klienten har redan översatt en knapp eller en fysisk tangent till en kod via config/panel_layout.json och config/web_keymap.json; kassan gör ingen tangentmappning, bara tolkning av koden. |
event_type | "make" eller "break" | Nedtryckning respektive uppsläppning. Obligatoriskt. Saknas det eller har ett annat värde kastas meddelandet - kassan gissar inte, eftersom en felgissning tyst skulle förstöra spårningen av Shift/Ctrl/Cmd. |
caps_lock | bool | Om Caps Lock var på. Valfritt, standard false. |
Varje tangent är två meddelanden. Kassan gör ingenting på make utom
att minnas vilka modifierare som var nedtryckta just då; funktionen
utförs på break, med modifierarna från tangentens egen make. En
knapp på skärmen skickar make och break direkt efter varandra.
Svaret är ett state-kuvert till alla anslutna flikar. Undantag: när
ett kortköp mot Nets pågår och make för C (CLEAR) kommer, avbryts
köpet i terminalen i stället, och meddelandet går aldrig till kassan.
Över WebSocket skickas då inget tillstånd; över HTTP blir svaret
{"cancelled": true}.
select_tab
{"destination": "operator_display", "type": "select_tab", "payload": {"tab_index": 5}}
Ett direkt klick på en flik i varupanelen. Till skillnad från att slå flikens nummer och trycka på fliktangenten rör det inte det inslagna beloppet.
select_line
{"destination": "operator_display", "type": "select_line", "payload": {"row_id": 7}}
Markerar en rad i kvittovyn (row_id från receipt_display_lines).
Markeringen tar inte bort raden - det gör BACKA, som då tar bort den
markerade raden. Ett felaktigt tryck på en pekskärm ska aldrig kunna
makulera en rad, och en makulering ska alltid journalföras.
HTTP-motsvarigheterna
HTTP-vägarna tar inte kuvertet, bara innehållet i payload, och
svarar med ett state-kuvert (som också sänds till alla
WebSocket-flikar):
| väg | kropp |
|---|---|
POST /key_press | {"scancode": 101, "event_type": "break", "caps_lock": false} |
POST /select_tab | {"tab_index": 5} |
POST /select_line | {"row_id": 7} |
POST /device_event | {"device_type": "scanner", "value": "7300000000000"} |
GET /state | - |
Saknade fält ger 400 med {"error": "..."}. Om kassatråden inte svarar
blir det 500 med {"error": "till thread unavailable"}.
POST /device_event är vägen för seriella inmatningsenheter -
streckkodsläsare, magnetkortsläsare, RFID - som receiptprinter
vidarebefordrar. device_type används bara i felmeddelanden; alla
enheter behandlas lika. Värdet tolkas först som en tillgodo-QR-kod
(om kassan har en signeringsnyckel), annars som en streckkod: finns
varan säljs den, annars går kassan till registering_product.
Statiska filer: / serverar operatörsklienten, /customer/ kundfönstret,
/gl/ ett experimentellt WebGL-skal, och /config/... filerna i
kassans konfigurationskatalog (till exempel panel_layout.json).
Exempel
Två rader är inslagna och operatören har tryckt SUBTOTAL:
{
"destination": "operator_display",
"type": "state",
"payload": {
"buffer": "0",
"mode": "price",
"prompt": null,
"barcode": null,
"terminal_display": null,
"cart_lines": [
{"group_name": "Kaffe", "amount_minor": 3500},
{"group_name": "Bullar", "amount_minor": 1250}
],
"subtotal_minor": 4750,
"vat_minor": 950,
"receipt_display_lines": [
{"columns": [{"text": "Kaffe", "percent": 0.0, "align": "left"},
{"text": "35:00", "percent": 85.0, "align": "right"}],
"row_id": 7, "style": "normal"},
{"columns": [{"text": "Bullar", "percent": 0.0, "align": "left"},
{"text": "12:50", "percent": 85.0, "align": "right"}],
"row_id": 8, "style": "normal"},
{"columns": [{"text": "SUBTOTAL", "percent": 0.0, "align": "left"},
{"text": "47:50", "percent": 85.0, "align": "right"}],
"row_id": null, "style": "bold_total"}
],
"customer_display": {
"row1": [{"text": "Att betala", "percent": 0.0, "align": "left"}],
"row2": [{"text": "47:50", "percent": 0.0, "align": "right"}]
},
"selected_line_event_id": null,
"active_tab_index": 0,
"last_error": null
}
}
(Momsen förutsätter 25 % på båda raderna: 700 + 250 öre.)
Direkt efter att raden “Kaffe” slogs in med multiplikatorn 2 såg kunddisplayen ut så här:
"customer_display": {
"row1": [{"text": "Kaffe", "percent": 0.0, "align": "left"}],
"row2": [{"text": "2", "percent": 0.0, "align": "left"},
{"text": "70:00", "percent": 50.0, "align": "right"}]
}
vilket på 2x20 blir:
Kaffe
2 70:00