A generic Tuya → KNX hub. Each Tuya device is controlled locally over the LAN (Tuya local protocol, per-device local key — no cloud) and exposed as KNX group objects for bidirectional control and status feedback. It runs as a .NET 10 console/worker host with an optional read-only status dashboard.
Device types are pluggable via profiles. The CREATE / IKOHS "Wind Calm" ceiling-fan-with-light
(Tuya category fsd, local protocol 3.3) is the first and currently only profile.
- What to build / why:
docs/PRD-MVP.md - Wind Calm device reference (datapoints, KNX group-object model, protocol quirks):
docs/use-cases/wind-calm/README.md
This README is the configuration guide: get credentials → configure the device → map to KNX → run.
- A host on the same LAN/VLAN broadcast domain as the Tuya devices.
- A KNXnet/IP gateway reachable for IP tunnelling (default UDP
3671). - Each device's
DeviceId,LocalKey, and IP address (see Step 1). - Don't run the Smart Life / Tuya app against a device while tuya-hub is connected: the module accepts only ~3 concurrent TCP sockets on port 6668, and tuya-hub keeps one persistent socket per device.
The LocalKey is a per-device secret. You extract it once (it requires the Tuya cloud), after
which tuya-hub uses it only locally — no cloud connection at runtime.
- Pair the device in the Smart Life (or Tuya) mobile app so it is bound to your Tuya account.
- Create a free Tuya IoT Platform cloud project (https://iot.tuya.com), and link your Smart Life account to it (Cloud → link app account / "Link Tuya App Account").
- Install tinytuya and run the wizard:
Enter the project's API key/secret and region when prompted. The wizard writes
pip install tinytuya python -m tinytuya wizarddevices.jsonlisting each device'sid(→DeviceId),key(→LocalKey), andip. - Confirm reachability / find IPs on the LAN:
python -m tinytuya scan
tuya-hub's own LAN discovery (dashboard, UC-01) passively
lists devices broadcasting on the LAN — showing DeviceId, IP, and protocol version, tagged "needs local
key". The beacon never carries the LocalKey, so you still supply that from the wizard above.
Caveats
- Re-pairing a device in the app can rotate its
LocalKey— re-run the wizard if a device stops connecting with an "invalid key" error. - Only protocol 3.3 devices are supported; 3.5-firmware units use framing tuya-hub can't decode.
There are two config paths using the same keys: edit appsettings.json directly (bare-metal / dev), or
supply environment variables via .env (Docker). Env vars override appsettings.json.
The shipped file ships everything disabled (Enabled: false). Fill in a device and enable it:
Per-device fields (TuyaOptions.Devices[]):
| Field | Meaning | Default |
|---|---|---|
Name |
Stable key; ties the device to its DeviceMappings entry. Required. |
— |
Profile |
Device type / profile id. | "wind-calm" |
Enabled |
Whether tuya-hub connects to this device. | true in code, ships false |
IpAddress |
Device LAN IP. | — |
DeviceId |
Tuya device id (gwId). |
— |
LocalKey |
Per-device local secret (Step 1). | — |
ProtocolVersion |
Local protocol version. | "3.3" |
Port |
Tuya local TCP port. | 6668 |
Add a second device by appending another element to the Devices array (each with its own Name).
Global TuyaOptions tunables:
| Key | Meaning | Default |
|---|---|---|
PollIntervalSeconds |
DP_QUERY poll cadence (catches RF-remote changes that don't push). |
10 |
HeartbeatIntervalSeconds |
Keep-alive interval. Only used by the 3.1/3.3 (TuyaNet) codec — 3.4/3.5 send no heartbeat and rely on the poll for keepalive. | 10 |
LivenessTimeoutSeconds |
Watchdog force-reconnect if no inbound byte within this window (must exceed the poll cadence; and the heartbeat where one is used). | 30 |
ConnectTimeoutSeconds |
TCP connect timeout per attempt. | 5 |
ReconnectInitialBackoffSeconds / ReconnectMaxBackoffSeconds |
Reconnect backoff bounds. | 1 / 30 |
Copy .env.example → .env (git-ignored) and set real values. Config binds via .NET's double-underscore
env-var convention:
TuyaOptions__Devices__0__Enabled=true
TuyaOptions__Devices__0__Name=LivingRoomFan
TuyaOptions__Devices__0__Profile=wind-calm
TuyaOptions__Devices__0__IpAddress=192.168.0.50
TuyaOptions__Devices__0__DeviceId=REPLACE_WITH_DEVICE_ID
TuyaOptions__Devices__0__LocalKey=REPLACE_WITH_LOCAL_KEY
TuyaOptions__Devices__0__ProtocolVersion=3.3
TuyaOptions__Devices__0__Port=6668A second device uses index __1__, a third __2__, and so on. KNX mappings are keyed by device Name:
DeviceMappings__<Name>__<MappingKey> (see Step 3).
Configure the gateway (KnxOptions):
| Key | Meaning | Default |
|---|---|---|
Enabled |
Enable the KNX bus. Set false to disable KNX entirely. |
ships false |
Host |
KNXnet/IP gateway IP (IP tunnelling). | — |
Port |
Gateway port. | 3671 |
IndividualAddress |
tuya-hub's KNX physical address. | e.g. 1.1.100 |
ReconnectInitialBackoffSeconds / ReconnectMaxBackoffSeconds |
Bus reconnect backoff bounds. | 1 / 30 |
Then map each device function to a KNX group address under DeviceMappings.<Name>. Command (KNX → device)
and status (device → KNX) are always separate group addresses. A missing or empty GA string
disables that function.
"DeviceMappings": {
"LivingRoomFan": {
"FanPower": "1/1/1", "FanPowerStatus": "1/1/2",
"FanSpeed": "1/1/3", "FanSpeedStatus": "1/1/4",
"FanDirection": "1/1/5", "FanDirectionStatus": "1/1/6",
"FanTimer": "1/1/7", "FanTimerStatus": "1/1/8",
"LightPower": "1/1/9", "LightPowerStatus": "1/1/10",
"LightCct": "1/1/13", "LightCctStatus": "1/1/14",
"LightCctStep": "1/1/16",
"AvailabilityStatus": "1/1/15"
}
}Valid mapping keys for the wind-calm profile, with their Tuya DP and KNX DPT:
| Mapping key | Direction | Tuya DP | KNX DPT |
|---|---|---|---|
FanPower / FanPowerStatus |
⇄ | 60 | 1.001 switch |
FanSpeed |
KNX → device | 62 | 5.001 % (absolute; 0 % = off, 1–100 % → levels 1–6) |
FanSpeedStatus |
device → KNX | 62 | 5.001 % (0 % = off; levels 1–6 → 17/33/50/67/83/100 %) |
FanDirection / FanDirectionStatus |
⇄ | 63 | 1.001 (0 = forward/summer, 1 = reverse/winter) |
FanTimer / FanTimerStatus |
⇄ | 64 | 7.006 minutes (0–540) |
LightPower / LightPowerStatus |
⇄ | 20 | 1.001 switch |
LightCct / LightCctStatus |
⇄ | 23 | 5.001 % → 3 discrete steps |
LightCctStep |
KNX → device | 23 | 3.007 dim step (relative long-press cycle) |
AvailabilityStatus |
device → KNX | — (connectivity-driven, no DP) | 1.001 switch |
Notes:
- Fan speed is an absolute percentage. The command GA takes a 5.001 % value that maps onto the six
device levels (
ceil(% / 100 × 6), clamped 1–6); 0 % turns the fan off. Setting a speed while off turns the fan on at that level. Current speed is reported on the separate 5.001 % status GA (0 % = off; levels 1–6 read back as 17/33/50/67/83/100 %). There is no relative dim-step. - The light is on/off + CCT only — no dimming. The Wind Calm hardware does not honour a brightness
write (DP 22), so brightness is not exposed. Use
LightPowerfor on/off andLightCctfor the 3-step colour temperature. - CCT can also be cycled by a long-press.
LightCctStepis an optional relative 3.007 command (same DP 23) for a KNX pushbutton: a long-press cycles cool → warm-white → warm → cool …, wrapping at the rails. It coexists with the absoluteLightCct; both are optional and independent, and the resulting step is reported on the sharedLightCctStatus. - Light CCT is flicker-prone. Leave
LightCct/LightCctStatusempty to skip it; the light then behaves as on/off-only. - See
docs/use-cases/wind-calm/README.mdfor the full datapoint reference and firmware quirks (integer-only fan speed, MCU-owned timer, RF-remote state drift).
DashboardOptions gates a read-only status page (and LAN discovery):
| Key | Meaning | Default |
|---|---|---|
Enabled |
Serve the dashboard + run LAN discovery. false = headless worker, no HTTP endpoint. |
true |
Port |
HTTP port. | 8080 |
When enabled, browse to http://<host>:8080/ for live per-device state plus a list of unconfigured Tuya
devices discovered broadcasting on the LAN.
Docker (recommended — see CLAUDE.md "Running in Docker"):
docker compose pull
docker compose up -dRequires network_mode: host (KNXnet/IP tunnelling and Tuya UDP discovery beacons need it); allow inbound
UDP 6666/6667 on the host firewall for the "Discovered" list to populate.
Local dev:
dotnet run --project TuyaHubVerify:
- Dashboard (
http://<host>:8080/) shows the device online. - Send to a command GA (e.g.
FanPower) from ETS / a KNX switch and confirm the fan reacts and the matching status GA + dashboard update. - Change the device with its physical RF remote and confirm the status GA updates within
PollIntervalSeconds. - On trouble, check logs (
docker compose logs -f) for connection / backoff / invalid-key messages.
- Another device: add a
Devices[]element (or__N__env vars) and a matchingDeviceMappings.<Name>block — no rebuild needed. - A new device type: register a new profile (its Tuya DP codec, KNX bindings, and aggregate). See the
profile architecture in CLAUDE.md and
docs/PRD-MVP.md.
{ "TuyaOptions": { "PollIntervalSeconds": 10, "HeartbeatIntervalSeconds": 10, "LivenessTimeoutSeconds": 30, "ConnectTimeoutSeconds": 5, "ReconnectInitialBackoffSeconds": 1, "ReconnectMaxBackoffSeconds": 30, "Devices": [ { "Name": "LivingRoomFan", "Profile": "wind-calm", "Enabled": true, "IpAddress": "192.168.0.50", "DeviceId": "REPLACE_WITH_DEVICE_ID", "LocalKey": "REPLACE_WITH_LOCAL_KEY", "ProtocolVersion": "3.3", "Port": 6668 } ] } }