|=====================================| | ArtiPulsarAPI Protocol Version 0.02 | | Written by: Michel van Osenbruggen | | CopyRight 2026 ArtiLED B.V. | |=====================================| Latest change : 05-10-2026 Status : Review draft Device : ArtiLED Pulsar (PLSR-001) Purpose : Bluetooth + 433 MHz RF transmitter for AV equipment. The Pulsar is deliberately "dumb": Control holds all codes and logic and sends raw Bluetooth HID reports and raw RF pulse trains; the Pulsar only transmits them, captures raw RF for learning, and reports state. Controlled by : Control only (the Hub goes through Control) |=====================| | Protocol Definition | |=====================| Protocol : HTTP Port : 80 Method : POST (/api); OTA see OTA URL : http:///api/... Authentication : token= Data : data= BT/RF request fields : ident, boot_id, session_id, command fields OTA : ElegantOTA (as Relay/Slave), incl. recovery mode |=============| | Error Codes | |=============| 1 : Login failed 2 : Invalid token 3 : Incomplete data (no token, no data, no field) 4 : Invalid data (any invalid or unknown field; whole request rejected, nothing saved) 6 : Invalid command (unknown /api path or command) 7 : Unauthorized IP (firewall); HTTP status 403 9 : Rollback not possible 10 : Busy; nothing sent 11 : Stale or wrong ident, boot_id or session_id 12 : Not admitted (sequence / request_id); nothing sent 13 : Host not connected; nothing sent Additional codes : TBD |====================| | Response Structure | |====================| success : 1 = success, 0 = error error : Numeric error code; 0 = none error_text : Error description execution_time : Request processing time, milliseconds data : Command-specific response fields Response : {"success":1,"error":0,"error_text":"Success", "execution_time":5,"data":{}} |=====================| | Management Commands | |=====================| > /api/alive : No token/data -> {"name":name,"ident":ident} > /api/login : data={"username":"...","password":"..."} -> {"token":token} > /api/version : token -> {"version":version,"protocol_version":version} > /api/info : token -> $ info > /api/config : token, data=$ config -> Saves the supplied fields, no reboot -> Returns the supplied fields as saved > /api/reset : token -> Returns {} -> Reboots > /api/rollback : token -> Returns {} -> Reboots into the previous firmware > /api/recovery : token -> Returns {} -> Reboots into recovery mode $ info : {"name":name,"ident":ident,"type":type,"serial":serial, "version":version,"protocol_version":version,"ip":ip, "mac":mac,"link_speed":mbps,"controller":ip, "firewall_api":0/1,"firewall_udp":0/1,"uptime":s, "boot_id":boot_id,"free_heap":bytes, "min_free_heap":bytes,"temperature":celsius, "capabilities":$ capabilities} $ config : {"name":name,"controller":ip,"firewall_api":0/1, "firewall_udp":0/1}; one or more fields Firewall : firewall_api=1 -> every /api path only from controller firewall_udp=1 -> UDP commands only from controller Recovery mode : OTA only; no /api $ info Pulsar : + "power_source":power_source,"host_id":host_id, "rssi":rssi $ capabilities : {"bt":{"profiles":[profile,...],"max_hosts":n}, "rf":{"frequency_min_hz":hz,"frequency_max_hz":hz, "modulations":["ook"],"min_pulse_us":us, "max_pulse_us":us,"max_pulses":n,"max_repeats":n, "max_gap_us":us,"max_tx_ms":ms}, "max_body_bytes":bytes} profiles = implemented profiles only Other keys: reserved |=====| | OTA | |=====| Authentication : HTTP basic auth, OTA login > /update : GET -> OTA page > /ota/start : GET ?mode=fr&hash= -> HTTP 200 -> Starts a firmware upload > /ota/upload : POST multipart: MD5=, firmware= -> HTTP 200, body OK -> Reboots |=========| | Session | |=========| > /api/session : token, data={command,...} open : event_port -> session_id, boot_id renew : session_id -> session_id close : session_id -> session_id Event destination : Authenticated caller IP + event_port Sessions : 1; open closes the previous session > /api/result : token, data={ident,boot_id,session_id,request_id} Response data : request_id, state |===========| | Admission | |===========| Namespace : Per session; HTTP and UDP share it New request : sequence > highest admitted sequence, request_id not retained -> admitted once Retry, retained : sequence <= highest; same request_id + payload as the retained result -> that result; nothing sent again Retry, evicted : sequence <= highest; no retained result -> error 12, state unknown; nothing sent Other : Any other request -> error 12; nothing sent hold_renew retry : That result; the lease is not extended again Old boot_id/session_id : Error 11 Session close/expiry : Session ends for good; its holds end; its results are dropped unknown : Outcome not known; never a reason to resend |====================| | Bluetooth Commands | |====================| > /api/bt : token, data={ident,boot_id,session_id,command,...} send : request_id, sequence, host_id, report (press, then neutral report after Tap release) hold_start : request_id, sequence, hold_id, host_id, report hold_renew : request_id, sequence, hold_id hold_stop : request_id, sequence, hold_id Response data : request_id, state > /api/bt/hosts : token, data={ident,boot_id,session_id,command,...} list : -> hosts [host_id, name, address, profile, connected, rssi] connect : host_id -> state (makes it the active device; the previous one is disconnected) disconnect : -> state remove : host_id -> state (forget the pairing) > /api/bt/pair : token, data={ident,boot_id,session_id,command,...} start : pair_id, profile, timeout_ms (the Pulsar becomes discoverable with that identity) status : pair_id -> state, host_id cancel : pair_id -> state Hold binding : session_id + host_id + link_id Hold end : hold_stop, expiry, session close/expiry, connect, disconnect, remove, pair start, link loss Release at hold end : Neutral report on the hold's own link only; never on another link; never replayed on reconnect Ended hold : Final; hold_renew/hold_stop -> its final state, nothing sent Overlap : send/hold_start while a send or hold is active -> error 10 Host change mid-tap : connect/disconnect/remove/pair start run after the tap's neutral report Not connected : Report to a host that is not connected -> error 13 |=============| | RF Commands | |=============| > /api/rf : token, data={ident,boot_id,session_id,command,...} send : request_id, sequence, frequency_hz, modulation, format, code, repeats, gap_us Response data : request_id, state > /api/rf/learn : token, data={ident,boot_id,session_id,command,...} start : learn_id, frequency_hz, modulation, timeout_ms status : learn_id -> state, capture_id, reason cancel : learn_id -> state > /api/rf/capture : token, data={ident,boot_id,session_id,capture_id} Response data : capture_id, frequency_hz, modulation, format, code, pulse_count, rssi_dbm Interpretation : Control only; the Pulsar does not decode protocols RF radio : One operation at a time: send or learn Busy : rf send or learn start while sending or learning -> error 10; no queue Operation end : sent | failed | learned | expired | stopped -> radio idle before the next operation Bluetooth : Independent of RF Capture complete : >= 2 pulses, then silence >= capture_gap_us Capture overflow : More than max_pulses -> failed, reason overflow Incomplete capture : Failed; no capture_id Scope : Fixed-code 433 MHz AV equipment (screens, masking, lifts). Rolling-code devices are not supported. Somfy is not supported (use a Somfy gateway). |===========| | Sequences | |===========| Host switch during hold: hold_start (host 1) -> connect host 2 -> neutral report to host 1 -> bt.stopped {"reason":"switched","released":1} -> host 1 disconnected -> host 2 connected -> hold_renew/hold_stop of that hold: state stopped, nothing sent Learn versus send : learn start -> rf send: error 10 -> learn cancel -> rf send -> rf.sent Retry after eviction : rf send (sequence 7) -> sent, reply lost -> result evicted -> same request: error 12, state unknown, nothing sent |===========| | Variables | |===========| name : 1-25 characters; no "|", no control characters controller : IPv4 address; 0.0.0.0 = none boot_id : 8 lowercase hex characters, random per boot uptime : Seconds since boot request_id : 1-32 characters [A-Za-z0-9_-]; unique per session sequence : Integer 1-2147483647, increasing per session ident : Target device ident session_id : Current control session ID host_id : Paired Bluetooth device, 1-based; 0 = none link_id : Bluetooth connection, +1 per connect (per boot) profile : Bluetooth identity the Pulsar presents when pairing: keyboard = HID keyboard + consumer (PS5, Steam) switch_pro = reserved (Nintendo Switch Pro Controller) A profile defines ONLY the identity/descriptor, never key meaning report : One raw HID input report, from Control: {"type":"keyboard","modifiers":m,"keys":[u,...]} m = 0-255 modifier bitmask u = 4-231 (0x04-0xE7, page 0x07), 0-6 distinct {"type":"consumer","usage":u} u = 1-65535 (page 0x0C) {"type":"gamepad",...} = reserved Report per profile : keyboard -> keyboard, consumer; other -> error 4 Neutral report : keyboard {"modifiers":0,"keys":[]}; consumer usage 0 hold_id : Unique held-report ID pair_id : Unique pairing ID frequency_hz : RF carrier, 387000000-464000000 (e.g. 433920000, 433420000); within capabilities modulation : ook; 2fsk reserved format : raw code : JSON array of pulse durations, microseconds, integers, alternating on/off, starting with on, even count, e.g. [350,1050,1050,350] Pulse: min_pulse_us-max_pulse_us (protocol 1-65535) Count: 2-max_pulses (protocol 2-1024) repeats : Additional repeats of code, 0-max_repeats (protocol 0-50) gap_us : Silence between repeats, 0-max_gap_us (protocol 0-1000000) Total TX : total_us = sum(code) x (repeats + 1) + gap_us x repeats total_us <= max_tx_ms x 1000, 64-bit, after the field range checks; max_tx_ms <= 10000 Over -> error 4, nothing sent learn_id : Unique RF learning ID capture_id : Retained RF capture ID capture_gap_us : 10000 timeout_ms : Pairing/learning timeout, milliseconds rssi : Bluetooth signal strength of the active connection, dBm; null = not measurable power_source : poe | adapter | unknown reason : stop | expired | session | switched | disconnected | removed | pairing | link_lost | cancelled | timeout | overflow | error released : 1 = neutral report sent; 0 = link gone, release not confirmed state : accepted | transmitting | sent | stopped | expired | failed | pairing | paired | connecting | connected | disconnected | learning | learned | unknown sent = local transmission completed (tap incl. neutral report), not target action |===================| | Timing and Limits | |===================| Session expiry : 60 s; renew every 20 s Tap release : Neutral report 50 ms after the press Hold expiry : 750 ms; renew every 250 ms; maximum hold 30 s Pairing timeout : Default 60000 ms; maximum 120000 ms Learning timeout : Default 15000 ms; maximum 60000 ms Host switch : connect to another host_id takes seconds; reports to a host that is not connected fail (no hidden queue) Result retention : 128 operations / 60 s; active operations retained Capture retention : 8 captures / 60 s Active transmissions : 1 Bluetooth + 1 RF; no hidden queue UDP initial reply wait : 250 ms; then HTTP result query, no automatic resend Event retransmission : 100 ms and 300 ms after first notification Paired hosts : Maximum TBD; advertised by /api/info RSSI reporting : bt.signal_poor below the "poor" threshold (TBD); bt.signal_ok back above it; once per crossing Code/body limits : Advertised by /api/info |===========| | Discovery | |===========| Protocol : UDP broadcast Port : 50087 Magic : 2k5jIulJ Interval : 30 seconds Data : 2k5jIulJ|ident|type|ip|name