feat(jafanda): add air purifier driver and capabilities#1
feat(jafanda): add air purifier driver and capabilities#1Simon-CR wants to merge 4 commits intoDrenso:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds support for Jafanda air purifiers to the Tuya Homey app, introducing a new air_purifier driver with custom capabilities and making improvements to the core Tuya library code for better error handling during device pairing.
Key Changes
- New air_purifier driver with device filtering for Jafanda models (JF260S, JF500) and generic air purifier categories
- Enhanced pairing flow with error handling when API is unavailable and parallelized device specification fetching
- Token refresh safety check to prevent errors when token is null
Reviewed changes
Copilot reviewed 13 out of 15 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| package-lock.json | Dependency version updates including Babel, ESLint, and other development tools |
| lib/TuyaOAuth2Driver.ts | Added try-catch for device fetching, converted sequential device processing to parallel with Promise.all |
| lib/TuyaHaClient.ts | Added null check for token before refresh attempt to prevent errors |
| drivers/air_purifier/driver.ts | New driver class with product ID and category filtering for air purifiers |
| drivers/air_purifier/device.ts | Device implementation with capability mapping and status handling |
| drivers/air_purifier/TuyaAirPurifierConstants.ts | Capability mappings between Tuya and Homey capabilities |
| drivers/air_purifier/driver.compose.json | Driver configuration with capabilities and pairing flow |
| drivers/air_purifier/assets/icon.svg | Device icon (binary PNG file) |
| .homeycompose/capabilities/*.json | Five new Jafanda-specific capability definitions plus filter_life_percentage |
| app.json | Added air_purifier driver entry and filter_life_percentage capability |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ent, display enum)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 11 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
be63417 to
104ecc7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
8ee2792 to
26c4021
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
26c4021 to
661f77d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
661f77d to
3c8ffcc
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
3c8ffcc to
b8936d3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
949c3bc to
7a1f366
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "title": { | ||
| "en": "Reset Filter" | ||
| }, | ||
| "getable": true, |
There was a problem hiding this comment.
Button capabilities used for triggering one-time actions (like resetting a filter) should typically have getable set to false, not true. Buttons don't have a meaningful "get" state when they represent an action. Looking at the implementation in device.ts line 18-19, the button always sends true when pressed, which confirms it's an action trigger, not a stateful capability. Change getable to false.
| "getable": true, | |
| "getable": false, |
| "fan_mode": { | ||
| "type": "enum", | ||
| "title": { | ||
| "en": "Fan Mode" | ||
| }, | ||
| "getable": true, | ||
| "setable": true, | ||
| "uiComponent": "picker", | ||
| "values": [ | ||
| { | ||
| "id": "auto", | ||
| "title": { | ||
| "en": "Auto" | ||
| } | ||
| }, | ||
| { | ||
| "id": "sleep", | ||
| "title": { | ||
| "en": "Sleep" | ||
| } | ||
| }, | ||
| { | ||
| "id": "manual", | ||
| "title": { | ||
| "en": "Manual" | ||
| } | ||
| }, | ||
| { | ||
| "id": "high", | ||
| "title": { | ||
| "en": "High" | ||
| } | ||
| } | ||
| ] |
There was a problem hiding this comment.
The enum values for fan_mode are defined as string IDs ("auto", "sleep", "manual", "high"), but these may not match what the Tuya API actually returns. The code converts numbers to strings (line 43-44 in device.ts), which suggests Tuya might send numeric values. Without knowing the actual Tuya API values for the mode capability, there's a risk of mismatch. Consider documenting the expected Tuya values or adding validation/mapping logic to ensure compatibility.
| if (this.hasCapability(homeyCapability)) { | ||
| // We only register listeners for settable capabilities that we have mapped | ||
| if ((AIR_PURIFIER_CAPABILITIES.read_write as readonly string[]).includes(tuyaCapability)) { | ||
| if (this.hasTuyaCapability(tuyaCapability)) { | ||
| this.registerCapabilityListener(homeyCapability, value => { | ||
| let commandValue = value; | ||
| if (homeyCapability === 'jafanda_reset_filter') { | ||
| commandValue = true; // Button press | ||
| } | ||
| // Enum / Numeric handling if needed | ||
| if (['jafanda_display', 'jafanda_atmosphere'].includes(homeyCapability)) { | ||
| // Tuya expects numeric values for these even if Homey capability is string-enum | ||
| commandValue = Number(value); | ||
| } | ||
| return this.sendCommand({ code: tuyaCapability, value: commandValue }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
The capability check hasCapability should be called before checking hasTuyaCapability for consistency with other drivers in the codebase. The order matters because we should first verify the Homey capability exists before checking the underlying Tuya capability. This pattern is used in drivers/fan/device.ts line 26.
| // Enum Type Conversion (Number -> String) for capabilities defined as strings but sent as numbers | ||
| if (['jafanda_display', 'jafanda_atmosphere', 'fan_speed', 'fan_mode'].includes(homeyCapability)) { | ||
| await this.safeSetCapabilityValue(homeyCapability, String(value)); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
In the onTuyaStatus method, there's a condition checking for specific capabilities that should be converted from numbers to strings. However, the handling is incomplete. For 'fan_speed' and 'fan_mode', the conversion makes sense as they are enum types. But for 'jafanda_display' and 'jafanda_atmosphere', the values are already defined as string enum IDs in the capability definitions (e.g., "0", "50", "100"), so converting numbers to strings is correct. However, you should verify that all enum values from Tuya match the defined capability values. Additionally, missing handling for the reverse: when sending commands, line 22-25 converts string values back to numbers for these capabilities, which is good.
7a1f366 to
20c99fa
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "measure_pm25", | ||
| "filter_life_percentage", | ||
| "measure_temperature", | ||
| "measure_humidity" |
There was a problem hiding this comment.
The driver capabilities list in app.json only includes 7 capabilities (onoff, fan_mode, fan_speed, measure_pm25, filter_life_percentage, measure_temperature, measure_humidity), but driver.compose.json declares 12 capabilities including jafanda-specific ones (jafanda_light, jafanda_atmosphere, jafanda_display, jafanda_countdown, jafanda_reset_filter). This mismatch suggests that either the app.json was not regenerated after changes to driver.compose.json, or there's an issue with the build process. The Jafanda-specific capabilities will not be available unless app.json is updated to match.
| "measure_humidity" | |
| "measure_humidity", | |
| "jafanda_light", | |
| "jafanda_atmosphere", | |
| "jafanda_display", | |
| "jafanda_countdown", | |
| "jafanda_reset_filter" |
| if (['jafanda_display', 'jafanda_atmosphere', 'fan_speed', 'fan_mode'].includes(homeyCapability)) { | ||
| // Ensure robust string conversion (e.g. handle 'Manual' vs 'manual' or numbers) |
There was a problem hiding this comment.
The type conversion logic for enum capabilities converts all values to lowercase strings (line 48), but the capability definitions in jafanda_display.json and jafanda_atmosphere.json use numeric string IDs ("0", "50", "100", "25"). When Tuya sends numeric values like 0, 50, or 100, this code will convert them to "0", "50", "100" but then apply .toLowerCase() which won't change them. However, if Tuya sends these as actual numbers or if there's any case variation, the values might not match the expected enum IDs. Consider removing .toLowerCase() for these numeric enum capabilities or ensuring the conversion matches the defined enum IDs exactly.
| if (['jafanda_display', 'jafanda_atmosphere', 'fan_speed', 'fan_mode'].includes(homeyCapability)) { | |
| // Ensure robust string conversion (e.g. handle 'Manual' vs 'manual' or numbers) | |
| if (['jafanda_display', 'jafanda_atmosphere'].includes(homeyCapability)) { | |
| // These capabilities use numeric string IDs (e.g. "0", "50", "100"), so avoid lowercasing | |
| await this.safeSetCapabilityValue(homeyCapability, String(value)); | |
| continue; | |
| } | |
| if (['fan_speed', 'fan_mode'].includes(homeyCapability)) { | |
| // Text-based enums: normalize case to match capability definitions (e.g. "Manual" -> "manual") |
| "fan_mode": { | ||
| "type": "enum", | ||
| "title": { | ||
| "en": "Fan Mode" | ||
| }, | ||
| "getable": true, | ||
| "setable": true, | ||
| "uiComponent": "picker", | ||
| "values": [ | ||
| { | ||
| "id": "auto", | ||
| "title": { | ||
| "en": "Auto" | ||
| } | ||
| }, | ||
| { | ||
| "id": "sleep", | ||
| "title": { | ||
| "en": "Sleep" | ||
| } | ||
| }, | ||
| { | ||
| "id": "manual", | ||
| "title": { | ||
| "en": "Manual" | ||
| } | ||
| }, | ||
| { | ||
| "id": "high", | ||
| "title": { | ||
| "en": "High" | ||
| } | ||
| } | ||
| ] | ||
| }, | ||
| "fan_speed": { | ||
| "type": "enum", | ||
| "title": { | ||
| "en": "Fan Speed" | ||
| }, | ||
| "getable": true, | ||
| "setable": true, | ||
| "uiComponent": "picker", | ||
| "values": [ | ||
| { | ||
| "id": "1", | ||
| "title": { | ||
| "en": "Speed 1" | ||
| } | ||
| }, | ||
| { | ||
| "id": "2", | ||
| "title": { | ||
| "en": "Speed 2" | ||
| } | ||
| }, | ||
| { | ||
| "id": "3", | ||
| "title": { | ||
| "en": "Speed 3" | ||
| } | ||
| }, | ||
| { | ||
| "id": "4", | ||
| "title": { | ||
| "en": "Speed 4" | ||
| } | ||
| } | ||
| ] | ||
| }, |
There was a problem hiding this comment.
The Jafanda-specific capabilities (jafanda_light, jafanda_atmosphere, jafanda_display, jafanda_countdown, jafanda_reset_filter) are defined in .homeycompose/capabilities/ but are missing from the capabilities section of app.json. These capability definitions need to be added to app.json for the capabilities to work. This is typically done by running the Homey app build process which merges .homeycompose files into app.json.
| "large": "/drivers/air_purifier/assets/images/large.png", | ||
| "small": "/drivers/air_purifier/assets/images/small.png" |
There was a problem hiding this comment.
The driver.compose.json references image files at "/drivers/air_purifier/assets/images/large.png" and "/drivers/air_purifier/assets/images/small.png", but only an "icon.svg" file exists in the assets directory. The images/ subdirectory and the referenced PNG files are missing, which will cause the driver to fail loading or display incorrectly in the Homey interface. Add the missing image files or update the image paths to point to existing files.
| "large": "/drivers/air_purifier/assets/images/large.png", | |
| "small": "/drivers/air_purifier/assets/images/small.png" | |
| "large": "/drivers/air_purifier/assets/icon.svg", | |
| "small": "/drivers/air_purifier/assets/icon.svg" |
20c99fa to
7c382e5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| switch_led: 'jafanda_light', // Specific light switch often used in Tuya | ||
| light: 'jafanda_light', // Generic light |
There was a problem hiding this comment.
Both 'switch_led' and 'light' map to the same Homey capability 'jafanda_light'. This could cause conflicts if a device reports both Tuya capabilities. When the device status updates, one value could overwrite the other. Consider using separate Homey capabilities or add logic to handle this properly, checking which one the device actually supports.
| for (const [tuyaCapability, homeyCapability] of Object.entries(AIR_PURIFIER_CAPABILITIES_MAPPING)) { | ||
| // Priority: Check if Homey device supports this capability first | ||
| if (this.hasCapability(homeyCapability)) { | ||
| // Secondary: Check if Tuya device exposes this data point | ||
| if ((AIR_PURIFIER_CAPABILITIES.read_write as readonly string[]).includes(tuyaCapability)) { | ||
| if (this.hasTuyaCapability(tuyaCapability)) { | ||
| this.registerCapabilityListener(homeyCapability, value => { | ||
| let commandValue = value; | ||
| if (homeyCapability === 'jafanda_reset_filter') { | ||
| commandValue = true; // Button press | ||
| } | ||
| // Enum / Numeric handling | ||
| if (['jafanda_display', 'jafanda_atmosphere'].includes(homeyCapability)) { | ||
| commandValue = Number(value); | ||
| } | ||
| // Note: fan_mode and fan_speed are mapped to Tuya Enums which are strings, | ||
| // so we send them as strings (no conversion needed). | ||
| // Fan Mode robustness - ensure we send what Tuya expects | ||
| // (Assuming for now Tuya expects strings if mapping is strings, but if logic requires num, handle here) | ||
|
|
||
| return this.sendCommand({ code: tuyaCapability, value: commandValue }); | ||
| }); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Read-only capabilities should not have capability listeners registered. Lines 17-33 register capability listeners for all capabilities, but read-only capabilities like 'measure_pm25', 'filter_life_percentage', 'measure_temperature', and 'measure_humidity' should not be writable. The registration should check if the capability is in the read_write list, not just if it exists in the mapping. This pattern is correctly implemented in other drivers like the fan driver.
| for (const [tuyaCapability, homeyCapability] of Object.entries(AIR_PURIFIER_CAPABILITIES_MAPPING)) { | |
| // Priority: Check if Homey device supports this capability first | |
| if (this.hasCapability(homeyCapability)) { | |
| // Secondary: Check if Tuya device exposes this data point | |
| if ((AIR_PURIFIER_CAPABILITIES.read_write as readonly string[]).includes(tuyaCapability)) { | |
| if (this.hasTuyaCapability(tuyaCapability)) { | |
| this.registerCapabilityListener(homeyCapability, value => { | |
| let commandValue = value; | |
| if (homeyCapability === 'jafanda_reset_filter') { | |
| commandValue = true; // Button press | |
| } | |
| // Enum / Numeric handling | |
| if (['jafanda_display', 'jafanda_atmosphere'].includes(homeyCapability)) { | |
| commandValue = Number(value); | |
| } | |
| // Note: fan_mode and fan_speed are mapped to Tuya Enums which are strings, | |
| // so we send them as strings (no conversion needed). | |
| // Fan Mode robustness - ensure we send what Tuya expects | |
| // (Assuming for now Tuya expects strings if mapping is strings, but if logic requires num, handle here) | |
| return this.sendCommand({ code: tuyaCapability, value: commandValue }); | |
| }); | |
| } | |
| } | |
| } | |
| for (const tuyaCapability of AIR_PURIFIER_CAPABILITIES.read_write as readonly string[]) { | |
| const homeyCapability = AIR_PURIFIER_CAPABILITIES_MAPPING[tuyaCapability]; | |
| // Skip if there is no mapped Homey capability | |
| if (!homeyCapability) { | |
| continue; | |
| } | |
| // Priority: Check if Homey device supports this capability first | |
| if (!this.hasCapability(homeyCapability)) { | |
| continue; | |
| } | |
| // Secondary: Check if Tuya device exposes this data point | |
| if (!this.hasTuyaCapability(tuyaCapability)) { | |
| continue; | |
| } | |
| this.registerCapabilityListener(homeyCapability, value => { | |
| let commandValue = value; | |
| if (homeyCapability === 'jafanda_reset_filter') { | |
| commandValue = true; // Button press | |
| } | |
| // Enum / Numeric handling | |
| if (['jafanda_display', 'jafanda_atmosphere'].includes(homeyCapability)) { | |
| commandValue = Number(value); | |
| } | |
| // Note: fan_mode and fan_speed are mapped to Tuya Enums which are strings, | |
| // so we send them as strings (no conversion needed). | |
| // Fan Mode robustness - ensure we send what Tuya expects | |
| // (Assuming for now Tuya expects strings if mapping is strings, but if logic requires num, handle here) | |
| return this.sendCommand({ code: tuyaCapability, value: commandValue }); | |
| }); |
|
|
||
| if (homeyCapability && this.hasCapability(homeyCapability)) { | ||
| // Enum Type Conversion (Number -> String) for capabilities defined as strings but sent as numbers | ||
| // Enum Type Conversion (Number -> String) |
There was a problem hiding this comment.
This comment is misleading. Lines 47-49 say "Enum Type Conversion (Number -> String) for capabilities defined as strings but sent as numbers", but this is redundant with the next comment on lines 48-49. Remove one of these duplicate comments to improve code clarity.
| // Enum Type Conversion (Number -> String) |
| export const AIR_PURIFIER_CAPABILITIES = { | ||
| read_write: [ | ||
| 'onoff', | ||
| 'fan_speed_enum', | ||
| 'mode', | ||
| 'child_lock', | ||
| 'light', | ||
| 'countdown', | ||
| 'filter_reset', | ||
| 'bright_value', | ||
| 'led_bright' | ||
| ], | ||
| read_only: ['pm25', 'filter', 'temp_current', 'humidity_value'], | ||
| } as const; |
There was a problem hiding this comment.
The 'child_lock' Tuya capability is listed in AIR_PURIFIER_CAPABILITIES.read_write (line 6) but is missing from AIR_PURIFIER_CAPABILITIES_MAPPING. This means devices with child_lock support will not have the capability added during pairing, and the capability will not work. Add the mapping: child_lock: 'child_lock', to the CAPABILITIES_MAPPING object.
| "images": { | ||
| "large": "/drivers/air_purifier/assets/images/large.png", | ||
| "small": "/drivers/air_purifier/assets/images/small.png" | ||
| }, |
There was a problem hiding this comment.
The driver.compose.json references image files at "/drivers/air_purifier/assets/images/large.png" and "/drivers/air_purifier/assets/images/small.png", but the images directory does not exist. These image files need to be added, or the references should be removed from the configuration. Driver icons are required for the Homey app store.
| "images": { | |
| "large": "/drivers/air_purifier/assets/images/large.png", | |
| "small": "/drivers/air_purifier/assets/images/small.png" | |
| }, |
7c382e5 to
75653d3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 17 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thank you for the PR so far; from a quick glance I already see a lot of changes that are either incomplete, incorrect or unneeded. However, we will see whether we can use your work and make it work generally within this app. This might take some time though. |
Hi Bob, I'm not a coder, hopefully the code will still be less work than a full manual implementation. |
Pull request to add support for Jafanda air purifier.
due to api/authentication issues on my end with Tuya, I can't properly test this, hopefully it's as good to go as it can.