Use panel miniapp to build a tap-to-run and automated wireless switch in CodeLab to implement the following capabilities through scene linkage:
For more information, see Panel MiniApp > Set up environment.
Product name: Wireless switch
The data points required by the wireless switch are listed in the table below. The data points of the scene switch can be added or deleted as needed based on the actual device, with the format of switch_type_${btnId}
.
DP ID | DP name | Identifier | Data transmission type | Data type | DP property |
1 | Button 1 | switch_type_1 | Report only (read-only) | enum | Enum values: single_click, double_click, long_press |
2 | Button 2 | switch_type_2 | Report only (read-only) | enum | Enum values: single_click, double_click, long_press |
3 | Button 3 | switch_type_3 | Report only (read-only) | enum | Enum values: single_click, double_click, long_press |
4 | Button 4 | switch_type_4 | Report only (read-only) | enum | Enum values: single_click, double_click, long_press |
Create a wireless switch, define the data points, and configure the data points in the panel.
Log in to Tuya Developer Platform and create a wireless switch.
switch_type_1
to switch_type_4
, and a wireless switch with four buttons is created, as shown in the following figure.Log in to Tuya MiniApp Developer Platform to create a panel miniapp.
For more information, see Panel MiniApp > Create panel miniapp.
Open IDE and create a panel miniapp based on the wireless switch tap-to-run template in Tuya MiniApp Tools.
For more information, see Panel MiniApp > Initialize project.
SmartDeviceSchema
of SDM
and get the button list based on the naming conventions of the data points of switch_type_${no}
.import { SmartDeviceSchema } from 'typings/sdm';
export const getSceneDps = (schema: SmartDeviceSchema) => {
if (!Array.isArray(schema)) return [];
return schema.map(s => s.code.match(/switch_type_(\d+)/)?.[1]);
};
getSceneDps
function.import React from 'react';
import { Image, ScrollView, View } from '@ray-js/ray';
import { useCreation } from 'ahooks';
import { useSelector } from 'react-redux';
import { DpSchema, useDevice } from '@ray-js/panel-sdk';
import { TopBar } from '@/components';
import { selectBindTapToRunRules } from '@/redux/modules/sceneSlice';
import { getSceneDps } from '@/utils/getSceneDps';
import { Scene } from './components/scene';
import styles from './index.module.less';
export function Home() {
const schema = useDevice(d => d.devInfo.schema);
const dpSchema = useDevice(d => d.dpSchema);
const devInfo = useDevice(d => d.devInfo);
const bindTapToRunRules = useSelector(selectBindTapToRunRules);
const sceneDpList = useCreation(() => {
return getSceneDps(schema).map(btnId => {
// Some logical codes that can be ignored temporarily.
});
}, [schema, bindTapToRunRules]);
return (
<View className={styles.view}>
<TopBar />
<View className={styles.content}>
<View className={styles.main}>
<View className={styles.logo}>
<Image src={devInfo.iconUrl} />
</View>
</View>
<ScrollView
style={{ maxHeight: '360px', height: 'auto' }}
className={styles.card}
refresherTriggered
scrollY
>
<Scene sceneDpList={sceneDpList} />
</ScrollView>
</View>
</View>
);
}
bindTapToRunRules
, is required during project initialization. As a result, the getBindTapToRunRules
asynchronous action needs to be dispatched using Redux.import React, { Component } from 'react';
import { Provider } from 'react-redux';
import { hideLoading, showLoading } from '@ray-js/ray';
import { devices, dpKit } from './devices';
import store from './redux';
import { getCurrentHomeInfoAsync, setIsInitialized } from './redux/modules/uiStateSlice';
import { getBindTapToRunRules, getTapToRunRulesAsync } from './redux/modules/sceneSlice';
import './styles/index.less';
interface Props {
devInfo: DevInfo;
// eslint-disable-next-line react/require-default-props
extraInfo?: Record<string, any>;
}
interface State {
devInfo: DevInfo;
}
const composeLayout = (SubComp: React.ComponentType<any>) => {
const { dispatch } = store;
return class PanelComponent extends Component<Props, State> {
async onLaunch(object: any) {
devices.common.init();
devices.common.onInitialized(device => {
dpKit.init(device);
// Block user actions before data is loaded.
showLoading({ title: '', mask: true });
Promise.all([dispatch(getBindTapToRunRules())]).finally(() => {
dispatch(setIsInitialized(true));
hideLoading();
});
dispatch(getTapToRunRulesAsync());
dispatch(getCurrentHomeInfoAsync());
});
}
render() {
const { extraInfo } = this.props;
return (
<Provider store={store}>
<SubComp extraInfo={extraInfo} {...this.props} />
</Provider>
);
}
};
};
export default composeLayout;
getBindTapToRunRules
API to get bindTapToRunRules
and writes it to Redux Store.export const getBindTapToRunRules = createAsyncThunk<BindTapToRunRules>(
'scene/getBindTapToRunRules',
async (_, thunkApi) => {
try {
const data = await devices.common.model.abilities.tapToRun.getBindTapToRunRules();
thunkApi.dispatch(sceneSlice.actions.initBindTapToRunRules(data));
return data;
} catch (error) {
return [];
}
}
);
export const selectTapToRunRules = (state: ReduxState) => state.scene.tapToRunRules;
useSelector
function to get the bindTapToRunRules
data and run the logical codes ignored before for determination of the binding status.const sceneDpList = useCreation(() => {
return getSceneDps(schema).map(btnId => {
let bindDpId: number;
let bindDpValue: string;
const bindScene = bindTapToRunRules?.find(b => {
const switchSceneSchema = dpSchema[`switch_type_${btnId}`] as DpSchema;
bindDpId = +b?.associativeEntityId.split('#')?.[0]; // Data points bound with tap-to-run scenes.
bindDpValue = b?.associativeEntityId.split('#')?.[1]; // Values of the data points bound with tap-to-run scenes.
if (switchSceneSchema?.id === bindDpId) {
return true;
}
return false;
});
const bindRule = bindScene?.associativeEntityValueList?.[0];
return {
btnId,
bindDpId,
bindDpValue,
bindScene,
bindRule,
};
});
}, [schema, bindTapToRunRules]);
btnId
, bindScene
, and bindRule
, to determine whether the current button is bound with a tap-to-run scene and display related information, such as the scene name.import React, { useState } from 'react';
import { useRequest } from 'ahooks';
import {
Text,
View,
Image,
router,
showLoading,
hideLoading,
showToast,
openEditScene,
} from '@ray-js/ray';
import { BindTapToRunRules } from '@ray-js/panel-sdk/lib/sdm/abilities/tapToRun/type';
import { ActionSheet, Dialog, DialogInstance, SwipeCell } from '@ray-js/smart-ui';
import Strings from '@/i18n';
import { Empty } from '@/components';
import { useAppDispatch } from '@/redux';
import { devices } from '@/devices';
import { unbindAsync, updateTapToRunRulesAsync } from '@/redux/modules/sceneSlice';
import { mapObject2QueryString } from '@/utils/mapObject2QueryString';
import { usePageReShow } from '@/hooks/usePageReShow';
import styles from './index.module.less';
interface Props {
sceneDpList: Array<{
btnId: string;
bindDpId?: number;
bindDpValue?: string;
bindScene?: BindTapToRunRules[number];
bindRule?: BindTapToRunRules[number]['associativeEntityValueList'][0];
}>;
}
enum SwitchType {
SingleClick = 'single_click',
DoubleClick = 'double_click',
LongPress = 'long_press',
}
const actions = Object.values(SwitchType).map(type => ({
id: type,
name: Strings.getLang(`sceneSwitchType_${type}`),
}));
export const Scene: React.FC<Props> = ({ sceneDpList }) => {
const dispatch = useAppDispatch();
const [showSwitchTypeDpId, setShowSwitchTypeDpId] = useState('');
// Refresh the data source after creating or editing the tap-to-run scene.
usePageReShow(() => {
dispatch(updateTapToRunRulesAsync());
});
const hideActionSheet = React.useCallback(() => {
setShowSwitchTypeDpId('');
}, []);
const { run: runTriggerRule } = useRequest(
(ruleId: string) => devices.common.model.abilities.tapToRun.trigger({ ruleId }),
{
manual: true,
loadingDelay: 1000,
onBefore: () => showLoading({ title: '', mask: true }),
onSuccess: (_, [ruleId]) => {
const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
hideLoading();
return showToast({
title: Strings.formatValue('sceneTriggerSuccess', scene.bindRule.name),
});
},
onError: (_, [ruleId]) => {
const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
hideLoading();
return showToast({
title: Strings.formatValue('sceneTriggerFailed', scene.bindRule.name),
icon: 'error',
});
},
}
);
const handleSelectSwitchType = React.useCallback(
data => {
const switchType = data?.detail?.id as string;
const path = mapObject2QueryString('/scene-select', {
dpId: showSwitchTypeDpId,
dpValue: switchType,
});
hideActionSheet();
return router.push(path);
},
[showSwitchTypeDpId]
);
const handleClose = React.useCallback((data: typeof sceneDpList[number]) => {
return event => {
const { position, instance } = event.detail;
switch (position) {
case 'cell':
instance.close();
break;
case 'right':
DialogInstance.confirm({
message: Strings.getLang('unbindTip'),
cancelButtonText: Strings.getLang('cancel'),
confirmButtonText: Strings.getLang('confirm'),
})
.then(() => {
const bindId = `${data?.bindScene?.bindId}`;
dispatch(unbindAsync({ bindId })).then(result => {
instance.close();
if (result.meta.requestStatus === 'rejected') {
showToast({
title: Strings.getLang('requestFailed'),
icon: 'error',
});
}
});
})
.catch(() => instance.close());
break;
default:
}
};
}, []);
const handleSceneItemClick = React.useCallback((data: typeof sceneDpList[number]) => {
return () => {
const { btnId, bindRule } = data;
if (!bindRule) {
setShowSwitchTypeDpId(`${btnId}`);
return;
}
DialogInstance.confirm({
message: Strings.getLang('triggerTip'),
cancelButtonText: Strings.getLang('cancel'),
confirmButtonText: Strings.getLang('confirm'),
})
.then(() => {
const triggerRuleId = data?.bindRule?.triggerRuleId;
runTriggerRule(`${triggerRuleId}`);
})
.catch(() => true);
};
}, []);
const handleClickSetting = React.useCallback((data: typeof sceneDpList[number]) => {
return (evt => {
evt?.origin?.stopPropagation();
const sceneId = data?.bindRule?.id;
if (data?.bindRule?.id) {
openEditScene({ sceneId });
}
}) as React.ComponentProps<typeof View>['onClick'];
}, []);
return (
<View>
{sceneDpList.length === 0 ? (
<Empty title={Strings.getLang('sceneEmptyTip')} />
) : (
<View>
{sceneDpList.map((d, idx) => {
const { bindRule, bindDpValue } = d;
const itemText = bindRule?.name
? `${Strings.getLang(`sceneSwitchType_${bindDpValue}` as any)}: ${bindRule?.name}`
: Strings.getLang('defaultName');
return (
<SwipeCell
key={idx}
asyncClose
rightWidth={65}
disabled={!bindRule} // Unbinding by swiping is not allowed for buttons with no binding rules.
slot={{
right: <View className={styles.right}>{Strings.getLang('unbind')}</View>,
}}
onClose={handleClose(d)}
>
<View className={styles.item} onClick={handleSceneItemClick(d)}>
<View className={styles.content}>
<View className={styles.textWrapper}>
<Text className={styles.textNo}>{idx + 1}</Text>
<Text className={styles.textScene}>{itemText}</Text>
</View>
</View>
{bindRule && (
<View onClick={handleClickSetting(d)}>
<Image className={styles.imageIcon} src="/images/icon_triangle.png" />
</View>
)}
</View>
</SwipeCell>
);
})}
</View>
)}
<Dialog id="smart-dialog" />
<ActionSheet
description={Strings.getLang('sceneSwitchTypeSelect')}
cancelText={Strings.getLang('cancel')}
show={!!showSwitchTypeDpId}
actions={actions}
onSelect={handleSelectSwitchType}
onClose={hideActionSheet}
onCancel={hideActionSheet}
/>
</View>
);
};
tapToRunRules
, is required during project initialization. As a result, the getTapToRunsAsync
asynchronous action needs to be dispatched using Redux.import React, { Component } from 'react';
import { Provider } from 'react-redux';
import { hideLoading, showLoading } from '@ray-js/ray';
import { devices, dpKit } from './devices';
import store from './redux';
import { getCurrentHomeInfoAsync, setIsInitialized } from './redux/modules/uiStateSlice';
import { getBindTapToRunRules, getTapToRunRulesAsync } from './redux/modules/sceneSlice';
import './styles/index.less';
interface Props {
devInfo: DevInfo;
// eslint-disable-next-line react/require-default-props
extraInfo?: Record<string, any>;
}
interface State {
devInfo: DevInfo;
}
const composeLayout = (SubComp: React.ComponentType<any>) => {
const { dispatch } = store;
return class PanelComponent extends Component<Props, State> {
async onLaunch(object: any) {
devices.common.init();
devices.common.onInitialized(device => {
dpKit.init(device);
// Block user actions before data is loaded.
showLoading({ title: '', mask: true });
Promise.all([dispatch(getBindTapToRunRules())]).finally(() => {
dispatch(setIsInitialized(true));
hideLoading();
});
dispatch(getTapToRunRulesAsync());
dispatch(getCurrentHomeInfoAsync());
});
}
render() {
const { extraInfo } = this.props;
return (
<Provider store={store}>
<SubComp extraInfo={extraInfo} {...this.props} />
</Provider>
);
}
};
};
export default composeLayout;
tapToRunRules
data, as shown in the following figure.bindAsync
asynchronous action to bind the current button with the tap-to-run scene and refresh the tap-to-run scene list. The dpId
and dpValue
parameters represent the button DP ID and button DP value, respectively. For example, if you double-tap on button 1 to enter the linkage selection page, the passed in DP ID is 1, and the passed in DP value is double_click. In this case, when button 1 reports double_click
, the bound tap-to-run scene is automatically triggered.import React from 'react';
import { Image, View, openCreateTapToRunScene, openEditScene, router, useQuery } from '@ray-js/ray';
import clsx from 'clsx';
import { useSelector } from 'react-redux';
import { TopBar, Empty, FixedBottom, RuleItem } from '@/components';
import Strings from '@/i18n';
import { getCachedSystemInfo } from '@/api/getCachedSystemInfo';
import { useHideMenuButton } from '@/hooks/useHideMenuButton';
import { useAppDispatch } from '@/redux';
import { TopBarHeight } from '@/components/top-bar';
import { usePageReShow } from '@/hooks/usePageReShow';
import {
bindAsync,
selectTapToRunRules,
updateTapToRunRulesAsync,
} from '@/redux/modules/sceneSlice';
import styles from './index.module.less';
import { SceneSelectQuery } from './index.type';
const FooterHeight = 88;
const { screenHeight } = getCachedSystemInfo();
const contentHeight = `${screenHeight - (TopBarHeight + FooterHeight)}px`;
export function SceneSelect() {
// Refresh the data source after creating or editing the tap-to-run scene.
usePageReShow(() => {
dispatch(updateTapToRunRulesAsync());
});
useHideMenuButton();
const dispatch = useAppDispatch();
const query: SceneSelectQuery = useQuery();
const tapToRunRules = useSelector(selectTapToRunRules);
const [fixedBottomHeight, setFixedBottomHeight] = React.useState('180rpx'); // iPhone 6 base width is used by default.
const [activeIdx, setActiveIdx] = React.useState(-1);
const isEmpty = tapToRunRules.length === 0;
const handleItemClick = React.useCallback((idx: number) => {
return () => setActiveIdx(idx);
}, []);
const handleSave = React.useCallback(() => {
const data = tapToRunRules?.[activeIdx];
dispatch(bindAsync({ dpId: query.dpId, dpValue: query.dpValue, ruleId: data.id }));
router.back();
}, [tapToRunRules, activeIdx]);
const handleAddClick = React.useCallback(() => {
const rule = tapToRunRules[activeIdx];
if (!rule) {
// Smart Life app 5.13.0 and later versions are supported.
openCreateTapToRunScene().catch(err => console.warn('openCreateTapToRunScene failed', err));
return;
}
openEditScene({ sceneId: rule.id });
}, [tapToRunRules, activeIdx]);
return (
<View className={styles.view}>
<TopBar title={Strings.getLang('sceneSelect')} isSubPage />
<View className={styles.content} style={{ marginBottom: fixedBottomHeight }}>
{isEmpty ? (
<Empty
style={{ height: contentHeight, marginTop: 0 }}
title={Strings.getLang('sceneSelectEmptyTip')}
/>
) : (
<View className={styles.list}>
{tapToRunRules?.map((data, idx) => {
const bindDeviceNums = data?.actions?.length ?? 0;
const isActive = activeIdx === idx;
return (
<RuleItem
key={idx}
isActive={isActive}
title={data.name}
subTitle={Strings.formatValue(
bindDeviceNums > 1 ? 'sceneBindDeviceNums' : 'sceneBindDeviceNum',
bindDeviceNums
)}
onClick={handleItemClick(idx)}
/>
);
})}
</View>
)}
<FixedBottom
className={styles.footer}
contentHeight="100rpx"
getFixedBottomHeight={setFixedBottomHeight}
>
<View
className={clsx(
styles.button,
(isEmpty || activeIdx === -1) && styles.button__disabled
)}
onClick={handleSave}
>
{Strings.getLang('save')}
</View>
<Image className={styles.imageAdd} src="/images/icon_add.png" onClick={handleAddClick} />
</FixedBottom>
</View>
</View>
);
}
export const bindAsync = createAsyncThunk<boolean, BindParams>(
'scene/bindAsync',
async (params, thunkApi) => {
await devices.common.model.abilities.tapToRun.bind({
dpId: params.dpId,
dpValue: params.dpValue,
ruleId: params.ruleId,
});
thunkApi.dispatch(updateBindTapToRunRulesAsync());
return true;
}
);
// Refresh the data source after creating or editing the tap-to-run scene.
usePageReShow(() => {
dispatch(updateTapToRunRulesAsync());
});
import { usePageEvent } from '@ray-js/ray';
import { useRef } from 'react';
/**
* The action is triggered only when the onShow event occurs for the second time to prevent situations that do not require list refresh for the first occurrence of the onShow event, such as page return from the app.
*/
export function usePageReShow(fn: (...arg: any) => any) {
const isMounted = useRef(false);
usePageEvent('onShow', () => {
if (isMounted.current) fn();
isMounted.current = true;
});
}
trigger
API of SmartTapToRunAbility
to trigger the tap-to-run scene. The codes are as follows:
const { run: runTriggerRule } = useRequest(
(ruleId: string) => devices.common.model.abilities.tapToRun.trigger({ ruleId }),
{
manual: true,
loadingDelay: 1000,
onBefore: () => showLoading({ title: '', mask: true }),
onSuccess: (_, [ruleId]) => {
const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
hideLoading();
return showToast({
title: Strings.formatValue('sceneTriggerSuccess', scene.bindRule.name),
});
},
onError: (_, [ruleId]) => {
const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
hideLoading();
return showToast({
title: Strings.formatValue('sceneTriggerFailed', scene.bindRule.name),
icon: 'error',
});
},
}
);
const handleSceneItemClick = React.useCallback((data: typeof sceneDpList[number]) => {
return () => {
const { btnId, bindRule } = data;
if (!bindRule) {
setShowSwitchTypeDpId(`${btnId}`);
return;
}
DialogInstance.confirm({
message: Strings.getLang('triggerTip'),
cancelButtonText: Strings.getLang('cancel'),
confirmButtonText: Strings.getLang('confirm'),
})
.then(() => {
const triggerRuleId = data?.bindRule?.triggerRuleId;
runTriggerRule(`${triggerRuleId}`);
})
.catch(() => true);
};
}, []);
triggerRuleId
is correct, the corresponding tap-to-run rule is valid, and the device is running properly, the tap-to-run scene will be triggered. Otherwise, a prompt indicating trigger failure will pop up.As described in the Bind button with tap-to-run scene section, you can trigger the tap-to-run scene by double tapping on physical button 1 for it to report double_click
. Note that the tap-to-run feature can be used when the network connection is available.
If no physical device is available, you can also use the device debugging function of Tuya Developer Platform to simulate the report operation to verify whether the developed functions are normal, as shown in the following figure.