Agreed 13 April 2026. A working document for implementation.
All sensitive data is kept on the phone only. The owner may optionally pass some of it through the server — with an explicit warning.
| Data | By default | Optionally through the server |
|---|---|---|
| Coordinates (lat, lon) | On the phone only | Can be shared with guests (with a warning) |
| WiFi fingerprint (BSSID[]) | On the phone only | Never transmitted |
| Webhook URL and secret | On the phone only | Can be stored on the server (with a warning) |
| Radius, time, confirmation level | Through the server | — |
When adding an object the owner picks a connection type. A settings window for that type opens next.
| Icon | Name | How it works | Feedback | Example |
|---|---|---|---|---|
| 📞 | Call | A phone call from the owner's phone | None (the call either went out or did not) | A barrier, an intercom |
| 🌐 | URL (webhook) | An HTTP request to the device's address, from the phone or from the Entrixy server | Yes — a JSON response with a status | A smart lock with an API, Tasmota, Shelly Cloud |
| 📟 | Internet controller (WS) | A command over WebSocket to a connected controller | Yes — a reply over the WebSocket | DIY ESP32 / ESP8266, Shelly Plus 1, NodeMCU |
| 📶 | BLE controller | A direct Bluetooth channel between phone and controller, with no internet | Yes — a reply over BLE (notify) | A battery-powered controller at a gate with no Wi-Fi |
The firmware configurator for the last two types: /controller/ (BLE and Internet, in the browser).
To the user every type looks the same: a card, a Call button, a status. Only the delivery mechanism differs.
The webhook settings window offers a switch, with the pros and cons spelled out on screen:
| Mode | Where the URL and secret are | Does the server know the URL? |
|---|---|---|
| From the phone | Prefs on the owner's phone | No |
| From the server | The table numbers in the database | Yes |
Security: an HMAC signature. The device and Entrixy share a webhook_secret.
POST https://device-url.com/open
Content-Type: application/json
{
"action": "open",
"object_id": 42,
"timestamp": 1713020000,
"nonce": "a1b2c3",
"signature": "hmac-sha256(secret, timestamp + nonce + action)"
}
The device checks that the signature is valid, the timestamp is under 30 seconds old and the nonce is not a repeat. If all is well it acts and replies:
{"status": "ok", "message": "Door opened"}
or
{"status": "error", "message": "Lock jammed"}
The status is shown to the user in the app.
The guest presses Call
→ the server receives the request
→ sends it to the owner over the WebSocket: {type: "do_webhook", ...}
→ the owner's phone makes the HTTP call to the device
→ receives the JSON response
→ sends it to the server: {type: "webhook_result", status, message}
→ the server passes it to the guest
→ both see the status in the log
In both modes the log on the owner's and the guest's phone records who, when, which object and what status came back.
The controller — ESP32, ESP32-S3, ESP32-C3 or ESP8266, all supported — has no public IP. It keeps an outbound WebSocket connection to the Entrixy server, exactly as the owner's phone does.
Firmware and configurator live at /esp/socket/. Sources: ESP32, ESP8266.
// Device → server: connect
{"type": "device_hello", "device_key": "xyz789", "device_secret": "..."}
// Server → device: OK
{"type": "device_ok"}
// Server → device: command
{"type": "device_command", "action": "open", "command_id": "abc123"}
// Device → server: result
{"type": "device_response", "command_id": "abc123", "status": "ok", "message": "Opened"}
The server relays the status to the clients — owner and guest — over their WebSocket connections.
device_key + device_secretwss://entrixy.com/wsThe object card shows the device's connection status, online or offline, just as guests see the owner's status.
A controller on the ESP32, S3, C3, C6 or H2 needs neither Wi-Fi nor a server to fire. It sits in deep sleep, wakes once a second for about 200 ms and broadcasts a BLE advertisement with a signed counter. When a phone holding a valid key comes near, Android — or the app, if it is in the foreground — catches the advertisement, connects over GATT, sends a fire command with a one-time nonce, and the controller closes the relay for a brief pulse. For bistable drives and locks there is a mode with separate open and close and position tracking, where open/closed travels as an encrypted byte in the advertisement.
The Entrixy server plays no part in this channel. It is needed only when a guest key is created: the owner signs a GuestToken and passes it to the guest over the end-to-end encrypted channel. After that the guest works offline.
The full protocol specification, bit layout and test vectors are at entrixy.com/ble. The threat model is in a separate document.
You can fit both BLE and WebSocket to the same drive — they appear as two separate objects in the app. BLE for opening up close without internet, WebSocket for the times you are far away.
entrixy-pair)Firmware and configurator live at /esp/ble/. Source: /esp32-example/.
On the object card: grey means not visible on the air, either too far or switched off; orange means visible but the automation has not fired; green means it fired or is firing; grey with a green outline means it fired recently and can be tapped to open again.
CREATE TABLE devices ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, host_id INT UNSIGNED NOT NULL, device_key VARCHAR(64) NOT NULL UNIQUE, secret_hash VARCHAR(64) NOT NULL, label VARCHAR(128) DEFAULT '', last_seen DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );
In server.php a third WebSocket role is added: device (alongside host and guest).
GPS and Wi-Fi work independently. With both configured, whichever condition is met first does the firing. The shared cooldown between firings is 20 seconds.
Mode 1: the Wi-Fi picture (fingerprint)
Mode 2: connected to a network
WifiManager.getScanResults() — cached results with no fresh scan; we call it on every GPS update at no costNetworkCallback, with no scanning at all
wifi_trigger_<actionKey> = {
"mode": "fingerprint" | "connect",
"bssids": ["AA:BB:CC:DD:EE:FF", ...],
"min_match": 5,
"connect_bssid": "AA:BB:CC:DD:EE:FF",
"connect_ssid": "TP-Link_5G",
"enabled": true
}
The owner decides whether confirmation is required. How identity is confirmed is up to the client, from whatever the device offers.
| Level | What the user sees | Example |
|---|---|---|
| Automatic | Nothing — the object opens by itself | Barriers, gates |
| Confirm | A notification in the shade: "Open [object]?" → one tap | A garage, a wicket gate, protection against false triggers |
| Confirm identity | Biometrics, the phone PIN or the app PIN | A front door |
"Confirm" is not a wasted step. The notification appears in the shade by itself at the right moment. No hunting for the app, no unlocking, no finding the object. One tap and the door is open.
BiometricPromptKeyguardManagerThe owner sets "Allowed hours: 07:00–23:00". Outside that window the condition is silently ignored. An overnight interval works too (22:00–06:00).
| Setting | Owner (their own) | Owner → guests | Guest |
|---|---|---|---|
| Object type | Chooses | Passes on | Receives |
| Coordinates | Sets them | Optional (with a warning) | Sets them or receives them |
| GPS radius | Sets it | Passes on | Receives |
| WiFi fingerprint | Scans | Never | Scans for themselves |
| Wi-Fi minimum matches | Sets it | Passes on | Receives |
| Time window | Sets it | Passes on | Receives |
| Confirmation | Sets it | Passes on | Receives |
| GPS on/off | yes | — | yes |
| Wi-Fi on/off | yes | — | yes |
The first step is choosing a connection type:
┌────────────────────────────┐ │ Connection type │ │ │ │ 📞 Call │ │ Opening with a phone │ │ call │ │ │ │ 🌐 URL │ │ Sending a command to │ │ a device address │ │ │ │ 📟 Device │ │ Connected through Entrixy │ │ (ESP32, Arduino) │ └────────────────────────────┘
After the choice, a settings window for that type opens.
┌──────────────────────────────────────────┐ │ Corner barrier 📶 3/5 312m │ │ Mine • ● │ ├──────────────────────────────────────────┤ │ [Edit] [Icon] │ │ ───────────────────────── │ │ [📍 Geo] [📶 Wi-Fi] │ └──────────────────────────────────────────┘
◉ By the Wi-Fi picture
○ By connection to a network
Below: the time window and confirmation, shared with Geo
| Trigger | Indicator |
|---|---|
| GPS | "312 m" |
| WiFi fingerprint | 📶 bars (0–4 by matches) |
| Wi-Fi connection | 📶 green or grey |
| Webhook/Device | ● green (online) or grey (offline) |
Internal plans. They are not shown to the user, but the limits apply.
| Default plan | Limit |
|---|---|
| Objects (numbers) | 10 |
| Guests (user_keys) | 10 |
On exceeding it, a gentle message: "The number of objects is limited. Contact support to raise it."
CREATE TABLE plans (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
max_numbers INT NOT NULL DEFAULT 10,
max_keys INT NOT NULL DEFAULT 10
);
INSERT INTO plans (name) VALUES ('default');
ALTER TABLE hosts ADD COLUMN plan_id INT UNSIGNED DEFAULT 1;
-- Plans
CREATE TABLE plans (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
max_numbers INT NOT NULL DEFAULT 10,
max_keys INT NOT NULL DEFAULT 10
);
INSERT INTO plans (name) VALUES ('default');
ALTER TABLE hosts ADD COLUMN plan_id INT UNSIGNED DEFAULT 1;
-- Object type and settings
ALTER TABLE numbers
ADD COLUMN type ENUM('call','webhook','device') DEFAULT 'call',
ADD COLUMN radius INT DEFAULT 80,
ADD COLUMN time_from TIME NULL,
ADD COLUMN time_to TIME NULL,
ADD COLUMN security_level ENUM('auto','confirm','identity') DEFAULT 'auto',
ADD COLUMN geo_available TINYINT(1) DEFAULT 1,
ADD COLUMN wifi_available TINYINT(1) DEFAULT 1,
ADD COLUMN wifi_min_match INT DEFAULT 5,
ADD COLUMN share_lat DOUBLE NULL,
ADD COLUMN share_lon DOUBLE NULL,
ADD COLUMN webhook_url VARCHAR(512) NULL,
ADD COLUMN webhook_secret VARCHAR(64) NULL,
ADD COLUMN webhook_mode ENUM('phone','server') DEFAULT 'phone',
ADD COLUMN device_id INT UNSIGNED NULL;
-- Devices (ESP32)
CREATE TABLE devices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
host_id INT UNSIGNED NOT NULL,
device_key VARCHAR(64) NOT NULL UNIQUE,
secret_hash VARCHAR(64) NOT NULL,
label VARCHAR(128) DEFAULT '',
last_seen DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
share_lat / share_lon — filled in only when the owner has enabled "Share coordinates with guests".
webhook_url / webhook_secret — filled in only in server mode. In phone mode they live in Prefs.
The owner sets a custom icon on an object and the guest should see it. But the icon is not stored on the server — the server acts as a postman: it delivers and forgets.
When the icon is set it is immediately shrunk to 64×64 PNG (about 3–5 KB) and kept locally in Prefs or a file.
1. The guest connects
→ guest_ok carries has_avatar: true for objects that have an icon
2. The guest client checks: has_avatar=true but no icon locally?
→ Yes → it asks the server
3. The server has no icon
→ it asks the owner over the WebSocket: {type: "avatar_request", number_id: 42}
4. The owner's phone
→ reads the local file
→ sends: {type: "avatar_data", number_id: 42, data: "base64..."}
5. The server passes it to the guest
→ {type: "avatar_data", number_id: 42, data: "base64..."}
6. The guest stores it locally and never asks again
7. The server stored nothing — the data merely passed through
| Situation | Behaviour |
|---|---|
| The owner is offline | The guest sees the default icon until the owner comes online. |
| 50 guests ask at once | 50 × 5 KB = 250 KB over the WebSocket — negligible. |
| The owner changes the icon | has_avatar_hash changes and guests request it again |
| The owner deletes the icon | has_avatar: false and guests show the default icon |
| Subscribers | Online at once (~15%) | WebSocket connections | Server |
|---|---|---|---|
| 1 000 | ~150 | ~150 | Easy |
| 10 000 | ~1 500 | ~1 500 | Easy |
| 50 000 | ~7 500 | ~7 500 | one worker at its limit |
| 100 000 | ~15 000 | ~15 000 | 2–3 workers |
Workerman on a single worker: 5,000–10,000 concurrent connections, at roughly 20 KB each.
is MySQL, not the WebSockets. But 250,000 inserts a day — 50k subscribers × 5 calls — is nothing for MySQL on an SSD.
Entrixy — final architecture v2, agreed 13 April 2026