POS Terminal Local Payments (NEXO)
Accept NEXO payments via USB tethering (RNDIS) or local network on Castle terminals. Covers driver setup, network config, and 1 EUR test on S1F2L.
Local NEXO bypasses the cloud. The terminal exposes https://<terminal_ip>:8443/nexo on the same LAN or over USB tethering. Use it when you need offline POS, faster receipt, or direct control from a Raspberry Pi / small host. Cloud is the alternative via the rox-pay-vcs API when the terminal is online and assigned to your company/store.
Hardware & Prerequisites
| Item | Details |
|---|---|
| Terminal | Castle S1F2 / S1F2L / S1E2L (Saturn1000F2), AMS1, P630 - tested on S1F2L-000158253425701 (0ca6:a050) |
| Firmware | Castles_Android 1.120.8+ (1.133.3 works), needs Local API enabled for the store |
| POIID | Model + serial: S1F2L-000158253425701 (with L), S1F2-000158253065912 (without). Wrong variant -> NotAllowed my POIID is S1F2L-... |
| Host | Any Linux host with rndis_host + cdc_ether (Pi Zero 2 W, Pi4, CachyOS, Ubuntu). Arduino Uno/Nano = device-device = no link, needs Host shield. |
Option A - USB Tethering (RNDIS) - Recommended for Pi
On the Castle: Settings -> Network -> Tethering -> USB Tethering ON. The device re-enumerates:
# before
Bus 005 Device 021: ID 0ca6:a050 ... bFunctionClass 2 CDC ACM
cdc_acm 5-1.4:1.0: ttyACM0
# after
Bus 005 Device 022: ID 0ca6:a050 ... bFunctionClass 224 Wireless RNDIS
rndis_host 5-1.4:1.0 usb0: register 'rndis_host' ... 66:c3:7b:df:57:74
rndis_host 5-1.4:1.0 enp103s0f3u1u4: renamed from usb0
On CachyOS/Debian the stock driver may keep cdc_acm on the RNDIS interface and bulk OUT 0x01 then times out (USBTimeoutError 110, SERIAL_STATE 00 00). Fix with a one-time rebind:
# /usr/local/bin/castle-rndis.sh
echo "5-1.4:1.0" > /sys/bus/usb/drivers/cdc_acm/unbind 2>/dev/null || true
echo "5-1.4:1.0" > /sys/bus/usb/drivers/rndis_host/bind 2>/dev/null || true
# wait for DHCP
for i in {1..10}; do ip -4 addr show | grep -q 192.168.42. && break; sleep 1; done
Udev to make it permanent:
# /etc/udev/rules.d/99-castle.rules
ACTION=="add", ATTR{idVendor}=="0ca6", ATTR{idProduct}=="a050", RUN+="/usr/local/bin/castle-rndis.sh"
Result on host:
ip -4 addr show enp103s0f3u1u4
# enp103s0f3u1u4: inet 192.168.42.121/24 brd 192.168.42.255
ip route show
# default via 192.168.42.129 dev enp103s0f3u1u4 proto dhcp
ping -c 2 192.168.42.129 # 0.5ms
# terminal is the gateway
ip neigh show dev enp103s0f3u1u4
# 192.168.42.129 lladdr ca:39:22:dd:ff:ad REACHABLE
192.168.42.129 is always the Castle, host gets 192.168.42.x via DHCP. No manual dhclient needed with NetworkManager.
Option B - WiFi / Local LAN
Connect the Castle to the same WiFi as the host: Settings -> Network -> WiFi -> <SSID>. Note IP in Settings -> Device Info -> IP (e.g. 192.168.1.205 or 192.168.1.40). Use that IP instead of 192.168.42.129 below. Ensure Local API is enabled for that store in the provider dashboard.
Cloud alternative via rox-pay-vcs: create a store order and use the POS endpoints. The backend forwards to the terminal via cloud when it is online and assigned to your company/store. No direct terminal IP needed.
# 1. Create a store order (example)
curl -X POST https://app.roxpay.eu/swiftline/cash-register/orders \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"store_id": 123, "items": [{"id": 1, "quantity": 1}]}'
# 2. Prepare POS payment for that order (cloud)
curl -X POST https://app.roxpay.eu/swiftline/cash-register/orders/{order_id}/pay/pos \
-H "Authorization: Bearer YOUR_TOKEN"
# 3. Complete after terminal response
curl -X POST https://app.roxpay.eu/swiftline/cash-register/orders/{order_id}/pay/pos/complete \
-H "Authorization: Bearer YOUR_TOKEN" -d '{"rbc_id": "TRX-..."}'
Check assignment in back-office: GET /swiftline/cash-register/terminals or /swiftline/cash-register/terminal (from POS cookie). If your S/N not listed -> assign it first (POST /swiftline/cash-register/terminals/assign with terminal_id = POIID and store_id).
Local NEXO API
- Endpoint:
https://<terminal_ip>:8443/nexo(GET->404,POSTonly) - TLS: Self-signed terminal certificate (test fleet). Use
curl -korrequests verify=False/WiFiClientSecure::setInsecure(). - Headers:
Content-Type: application/jsononly (local needs no API key). - POIID must match exactly including
L:S1F2L-...vsS1F2-.... - ProtocolVersion:
3.0,MessageClass Service,MessageCategory PaymentorPrint.
Payment - 1 EUR Test
Validated on S1F2L-000158253425701 via RNDIS (enp... 192.168.42.121 -> 192.168.42.129):
curl -k --request POST --url 'https://192.168.42.129:8443/nexo' \
--header 'Content-Type: application/json' --data '{
"SaleToPOIRequest": {
"MessageHeader": {
"ProtocolVersion": "3.0",
"MessageClass": "Service",
"MessageCategory": "Payment",
"MessageType": "Request",
"SaleID": "USBTEST-001",
"ServiceID": "000003",
"POIID": "S1F2L-000158253425701"
},
"PaymentRequest": {
"SaleData": {
"SaleTransactionID": {
"TransactionID": "USBTEST-001",
"TimeStamp": "2026-08-31T12:10:00Z"
},
"SaleToAcquirerData": "shopperReference=usb-test&recurringProcessingModel=UnscheduledCardOnFile&authorisationType=Final&manualCapture=false"
},
"PaymentTransaction": {
"AmountsReq": {
"Currency": "EUR",
"RequestedAmount": 1.00
}
}
}
}
}'
Success response (Result Success):
{
"SaleToPOIResponse": {
"MessageHeader": {"POIID":"S1F2L-000158253425701","SaleID":"USBTEST-001","ServiceID":"000003"},
"PaymentResponse": {
"PaymentResult": {
"AmountsResp": {"AuthorizedAmount":1,"Currency":"EUR"},
"OnlineFlag": true,
"PaymentAcquirerData": {"AcquirerPOIID":"S1F2L-000158253425701","ApprovalCode":"123456"},
"PaymentInstrumentData": {"CardData":{"MaskedPan":"411111 **** 0001","PaymentBrand":"visa","EntryMode":["Contactless"]}}
},
"Response": {
"Result": "Success",
"AdditionalResponse": "pspReference=C4PPDMXVT8VQ3M75&authorisedAmountValue=100&posEntryMode=CLESS_CHIP&tid=53425701&refusalReasonRaw=APPROVED&store=STR-20260826-DPV9H25UD7&..."
}
}
}
}
AuthorizedAmount 1= €1.00,MaskedPan 411111is test card. On live with real card it is a real capture.- Wrong
POIID S1F2-...->{"Result":"Failure","ErrorCondition":"NotAllowed","AdditionalResponse":"message=MessageHeader.POIID: NotAllowed Value: S1F2-000158253425701, Reason: my POIID is S1F2L-000158253425701"}
Generate fresh IDs per request: SaleID = TransactionID, ServiceID = random 1_000_000_000..9_999_999_999, TimeStamp = UTC YYYY-MM-DDTHH:MM:SSZ.
Print Receipt Test (no charge)
curl -k --request POST --url 'https://192.168.42.129:8443/nexo' \
--header 'Content-Type: application/json' --data '{
"SaleToPOIRequest": {
"MessageHeader": {"ProtocolVersion":"3.0","MessageClass":"Device","MessageCategory":"Print","MessageType":"Request","ServiceID":"PRINT01","SaleID":"POSPRINT-001","POIID":"S1F2L-000158253425701"},
"PrintRequest": {
"PrintOutput": {
"DocumentQualifier": "Document",
"ResponseMode": "PrintEnd",
"OutputContent": {
"OutputFormat": "Text",
"OutputText": [
{"Text":"LODGE EASY","CharacterStyle":"Bold","Alignment":"Centred","EndOfLineFlag":true},
{"Text":"Test print via USB tethering","Alignment":"Centred","EndOfLineFlag":true},
{"Text":"Amount: EUR 1.00","Alignment":"Left","EndOfLineFlag":true},
{"Text":"Thank you!","Alignment":"Centred","EndOfLineFlag":true}
]
}
}
}
}
}'
# -> {"PrintResponse":{"Response":{"Result":"Success"}}}
Small Hardware: Raspberry Pi
Pi Zero 2 W or Pi4 as host is enough. Castle USB-C -> Pi USB-A (Pi4) or OTG C->A (Zero). Keep tethering ON.
# /home/pi/castle_pay.py - pip install requests
import requests, json, datetime, random, urllib.parse
POIID="S1F2L-000158253425701"
URL="https://192.168.42.129:8443/nexo"
def pay(amount_eur=1.00):
txn=f"PI-{int(datetime.datetime.now().timestamp())}"
sid=str(random.randint(1000000000,9999999999))
ts=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
payload={
"SaleToPOIRequest":{
"MessageHeader":{"ProtocolVersion":"3.0","MessageClass":"Service","MessageCategory":"Payment","MessageType":"Request","SaleID":txn,"ServiceID":sid,"POIID":POIID},
"PaymentRequest":{
"SaleData":{"SaleTransactionID":{"TransactionID":txn,"TimeStamp":ts},"SaleToAcquirerData":urllib.parse.urlencode({"shopperReference":"pi","recurringProcessingModel":"UnscheduledCardOnFile","authorisationType":"Final","manualCapture":"false"})},
"PaymentTransaction":{"AmountsReq":{"Currency":"EUR","RequestedAmount":float(amount_eur)}}
}
}
}
r=requests.post(URL, json=payload, verify=False, timeout=30)
j=r.json()
print(json.dumps(j, indent=2))
return j["SaleToPOIResponse"]["PaymentResponse"]["Response"]["Result"]=="Success"
if __name__=="__main__":
pay(1.00)
For Arduino/ESP32: needs Host + RNDIS stack (e.g. ESP32-S3 TinyUSB host). Easier: use ESP32 WiFi to 192.168.42.129 or to the terminal's WiFi IP and same POST with WiFiClientSecure::setInsecure() + HTTPClient. Uno without Host shield cannot do USB.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
NotAllowed my POIID is S1F2L-... | Used S1F2- without L | Use exact lsusb serial S1F2L-000158253425701 |
USBTimeoutError 110 bulk OUT | Host still on cdc_acm not rndis_host | Run castle-rndis.sh rebind as above, check ls -l /sys/bus/usb/devices/5-1.4:1.0/driver -> rndis_host and enp... appears |
No enp... / usb0 | rndis_host not loaded | modprobe rndis_host cdc_ether, re-plug, check dmesg rndis_host ... register |
404 on GET /nexo | GET not allowed | Use POST only |
Configurable USB Mode greyed | Managed by provider, boarded | Change via provider dashboard -> Terminal Settings, or reassign to Inventory first |
Ping 192.168.42.129 fails | Tethering off or wrong driver | Settings -> Tethering ON, rebind as above, ip addr should show 192.168.42.x |
| Cloud POS not finding terminal | Not assigned to your company/store | POST /swiftline/cash-register/terminals/assign with terminal_id = POIID and store_id |
Cloud vs Local
Local (192.168.42.129:8443 / 192.168.1.x:8443) | Cloud (rox-pay-vcs pay/pos) | |
|---|---|---|
| Needs internet | No (offline works) | Yes, terminal must be online and assigned |
| Needs assignment | No | Yes (/terminals/assign) |
| Auth | None (-k) | Authorization: Bearer YOUR_TOKEN (rox-pay-vcs) |
| Latency | ~6s for 1 EUR | ~90s timeout handled by backend |
Keep USB Tethering ON for Pi. For pure WiFi, use the WiFi IP instead of 192.168.42.129 - same payload.