Catatan proses pengembangan — dari kebutuhan sederhana “AC nyala/mati otomatis sesuai suhu ruang server” sampai menjadi dashboard web kecil yang jalan 24/7 via cron, lengkap dengan hysteresis, jadwal, dan pengamanan akses.
Latar Belakang
Ruang server kami punya 3 unit AC (merek Panasonic, Daikin, dan Gree) yang semuanya sudah dikonversi jadi “smart AC” lewat modul IR Tuya (kategori device infrared_ac di Tuya Cloud). Sensor suhu ruangan dikirim ke InfluxDB oleh perangkat IoT terpisah berbasis modul esp8266 nodemcu dengan sensor suhu sht31. Daripada menyalakan/mematikan AC manual, saya bangun satu aplikasi kecil PHP yang:
- Membaca suhu ruang server terbaru dari InfluxDB.
- Menentukan AC mana yang seharusnya ON berdasarkan jadwal jam.
- Menyalakan AC cadangan (standby) otomatis kalau suhu naik di luar jadwal, lalu mematikannya lagi begitu suhu sudah turun cukup jauh (hysteresis) — supaya AC tidak hidup-mati berulang (flapping) di sekitar ambang suhu.
- Menampilkan semuanya di dashboard web sederhana yang juga bisa dipakai untuk override manual.
Semua logic disusun untuk jalan di server Linux biasa dengan PHP 7.4 + Apache/Nginx, tanpa framework, tanpa database tambahan — supaya mudah dirawat dan gampang dibaca satu file demi satu file.
Arsitektur Singkat
[Sensor suhu] → InfluxDB 2.x ←── influx_temp() ──┐
│
[Tuya Cloud API] ←── tuya() / token() ─────────────┤── tuya_lib.php (logic inti)
│
index.php (dashboard) ←┤
ac_automation.php (cron 1 menit) ┘
tuya_lib.php— satu file “library” berisi semua fungsi inti: baca konfigurasi dari.env, client Tuya Cloud (signing HMAC-SHA256), reader InfluxDB, logika jadwal + hysteresis, dan fungsi automation_run() yang dipanggil baik dari dashboard maupun dari cron.index.php— dashboard web: menampilkan suhu real-time, status ON/OFF tiap AC, jadwal vs status aktual, dan tombol override manual (dengan CSRF token + konfirmasi).ac_automation.php— entrypoint CLI yang dipanggil cron setiap menit, menjalankan satu “pass” otomasi dan menulis log JSON.- 7 script debug/testing kecil (
test_tuya.php,inspect_devices.php,debug_flux.php,list_influx_*.php) — alat read-only untuk ngecek koneksi Tuya & query InfluxDB manual saat development/troubleshooting, tidak dipakai oleh aplikasi utama.
Kenapa Tuya Cloud API, Bukan Lokal?
AC-AC ini dikendalikan lewat modul IR universal yang terdaftar di akun Tuya (app Smart Life / Tuya Smart), bukan smart plug lokal. Jadi satu-satunya jalur kontrol programatik adalah Tuya Cloud API (OpenAPI), yang perlu:
- Project di Tuya IoT Platform dengan
Access ID+Access Secret. - Device dikaitkan (linked) ke project tersebut lewat skema otorisasi Tuya (biasanya App Account Linking).
- Semua request ditandatangani manual: HMAC-SHA256 dari
client_id + access_token + timestamp + nonce + string_to_sign, sesuai spesifikasi Tuya — tidak ada SDK resmi untuk PHP murni, jadi signing ditulis sendiri.
Inti signing-nya ada di tuya_lib.php:
function tuya($method, $path, $body = '', $token = '')
{
$id = cfg('TUYA_ACCESS_ID');
$secret = cfg('TUYA_ACCESS_SECRET');
$base = rtrim(cfg('TUYA_BASE_URL', 'https://openapi.tuyaus.com'), '/');
if (!$id || !$secret) throw new Exception('Tuya credentials are not configured');
$t = (string)round(microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
$payload = is_array($body) ? json_encode($body, JSON_UNESCAPED_SLASHES) : (string)$body;
$hash = hash('sha256', $payload);
$str = strtoupper($method) . "\n" . $hash . "\n\n" . $path;
$sign = strtoupper(hash_hmac('sha256', $id . $token . $t . $nonce . $str, $secret));
$headers = [
'client_id: ' . $id,
't: ' . $t,
'nonce: ' . $nonce,
'sign_method: HMAC-SHA256',
'sign: ' . $sign,
'lang: en',
];
if ($token) $headers[] = 'access_token: ' . $token;
if ($payload !== '') $headers[] = 'Content-Type: application/json';
$c = curl_init($base . $path);
curl_setopt_array($c, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 10,
]);
$raw = curl_exec($c);
$http = curl_getinfo($c, CURLINFO_HTTP_CODE);
$err = curl_error($c);
curl_close($c);
if ($raw === false) throw new Exception('Tuya transport error: ' . $err);
$j = json_decode($raw, true);
if (!is_array($j) || !($j['success'] ?? false)) {
throw new Exception('Tuya API error ' . (isset($j['code']) ? $j['code'] : $http) . ': ' . (isset($j['msg']) ? $j['msg'] : 'request failed'));
}
return $j['result'] ?? [];
}
Token diambil sekali dan di-cache ke file sementara sampai hampir expired, supaya tidak minta token baru di setiap request:
function token()
{
$f = sys_get_temp_dir() . '/tuya_token_' . substr(md5(cfg('TUYA_ACCESS_ID')), 0, 8) . '.json';
if (file_exists($f)) {
$c = json_decode(@file_get_contents($f), true);
if (!empty($c['access_token']) && ($c['expires_at'] ?? 0) > time() + 60) return $c['access_token'];
}
$r = tuya('GET', '/v1.0/token?grant_type=1');
if (empty($r['access_token'])) throw new Exception('Token response missing');
@file_put_contents($f, json_encode(['access_token' => $r['access_token'], 'expires_at' => time() + (int)($r['expire_time'] ?? 7200)]));
@chmod($f, 0666);
return $r['access_token'];
}
Perangkap: IR AC Tidak Punya DP Boolean power
AC jenis infrared (kategori infrared_ac) di Tuya tidak selalu punya data point boolean power/switch seperti smart plug biasa. Banyak device seperti ini hanya mengekspos command PowerOn / PowerOff lewat endpoint /commands. Jadi fungsi kontrolnya ditulis dengan fallback:
function ac_set_power($id, $on, $tk)
{
// IR-type AC (category infrared_ac) exposes PowerOn/PowerOff commands, not a boolean 'power' DP.
$cmd = $on ? ['code' => 'PowerOn', 'value' => true] : ['code' => 'PowerOff', 'value' => true];
try {
return tuya('POST', '/v1.0/devices/' . rawurlencode($id) . '/commands', ['commands' => [$cmd]], $tk);
} catch (Throwable $e) {
// Fallback for devices that use a boolean power/switch DP
if (strpos($e->getMessage(), '2008') === false) throw $e;
$dp = cfg('TUYA_DP_POWER', 'power');
return tuya('POST', '/v1.0/devices/' . rawurlencode($id) . '/commands',
['commands' => [['code' => $dp, 'value' => (bool)$on]]], $tk);
}
}
Kode error 2008 dari Tuya (data point tidak dikenal) ditangkap dan otomatis fallback ke DP boolean biasa — jadi fungsi ini tetap kompatibel kalau suatu saat ada device non-IR yang ditambahkan.
Membaca Suhu dari InfluxDB
Sensor suhu mengirim data ke InfluxDB 2.x (bucket + measurement + field + tag device_id). Query-nya pakai Flux, bukan InfluxQL:
function influx_temp($sensor = 'sensor_02', $field = 'value')
{
$u = rtrim(cfg('INFLUX_URL'), '/');
$org = cfg('INFLUX_ORG');
$bucket = cfg('INFLUX_BUCKET');
$tok = cfg('INFLUX_TOKEN');
if (!$u || !$org || !$bucket || !$tok) throw new Exception('InfluxDB config incomplete');
$q = 'from(bucket: ' . json_encode($bucket) . ') |> range(start: -' . cfg('INFLUX_LOOKBACK', '2h') . ')'
. ' |> filter(fn: (r) => r._measurement == ' . json_encode(cfg('INFLUX_MEASUREMENT', 'temperature')) . ')'
. ' |> filter(fn: (r) => r._field == ' . json_encode($field) . ')'
. ' |> filter(fn: (r) => r.device_id == ' . json_encode($sensor) . ')'
. ' |> last()';
$c = curl_init($u . '/api/v2/query?org=' . rawurlencode($org));
curl_setopt_array($c, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $q,
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $tok,
'Content-Type: application/vnd.flux',
'Accept: application/csv',
],
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($c);
$http = curl_getinfo($c, CURLINFO_HTTP_CODE);
curl_close($c);
if ($raw === false || $http < 200 || $http >= 300) throw new Exception('InfluxDB query failed (HTTP ' . $http . ')');
$val = null;
$time = null;
$hdr = null;
foreach (preg_split('/\r?\n/', trim($raw)) as $line) {
$a = str_getcsv($line);
// first row = column names
if ($hdr === null) {
$hdr = $a;
continue;
}
if (count($a) < count($hdr)) continue;
$row = array_combine($hdr, array_slice($a, 0, count($hdr)));
if (($row['result'] ?? '') !== '_result' || !is_numeric($row['_value'] ?? '')) continue;
$val = (float)$row['_value'];
$time = $row['_time'];
}
if ($val === null) return null;
return ['temperature' => $val, 'time' => $time, 'sensor' => $sensor];
}
Response InfluxDB v2 query API berbentuk CSV (bukan JSON), jadi di-parse manual dengan str_getcsv() per baris, mengambil baris dengan result == "_result" dan nilai numerik di kolom _value.
Jadwal + Hysteresis (Logika Inti)
Dua AC utama (Panasonic & Daikin) jalan dengan jadwal jam fixed, saling bergantian:
// 06:00-17:59 Panasonic ON / Daikin OFF ; 18:00-05:59 Daikin ON / Panasonic OFF
function schedule_target($ts = null)
{
$h = (int)date('G', $ts === null ? time() : $ts);
$day = ($h >= 6 && $h < 18);
// Gree is the standby unit: not on a fixed schedule, OFF by default and
// only forced ON while the temperature override is latched (see below).
return ['ac2' => $day, 'daikin' => !$day, 'gree' => false];
}
AC ketiga (Gree) berperan sebagai unit standby/cadangan: normalnya OFF, baru dipaksa ON kalau suhu ruangan naik di luar jadwal normal — dan harus tetap ON sampai suhu benar-benar turun, bukan langsung mati begitu menyentuh ambang. Inilah hysteresis:
// Standby AC turns ON above TARGET_TEMP_C and only turns OFF again once the
// temperature has dropped to TARGET_TEMP_C - HYSTERESIS_C. The decision is
// stateful: $current is the persisted override flag from the state file, so a
// temperature hovering around the upper threshold cannot flap the AC.
function override_decide($temp, $current)
{
if ($temp === null) return (bool)$current;
$th = override_thresholds();
if (!$current && $temp > $th['on']) return true;
if ($current && $temp <= $th['off']) return false;
return (bool)$current;
}
function override_thresholds()
{
$on = (float)cfg('TARGET_TEMP_C', '24');
return ['on' => $on, 'off' => $on - (float)cfg('HYSTERESIS_C', '1')];
}
Dengan TARGET_TEMP_C=24 dan HYSTERESIS_C=1: AC standby nyala begitu suhu > 24°C, dan baru mati setelah suhu turun ke ≤ 23°C. Rentang 1°C ini mencegah AC hidup-mati berulang (flapping) saat suhu mondar-mandir tepat di 24°C. Status “latched” ini disimpan ke file state (tuya_ac_state.json) agar bertahan antar-eksekusi cron.
Otomasi via Cron: Satu “Pass” per Menit
ac_automation.php adalah entrypoint CLI tipis yang dipanggil cron setiap menit:
<?php
// ac_automation.php - CLI entrypoint for cron: one automation pass, prints JSON log line
// Usage: php ac_automation.php [--dry-run] [--force]
chdir(__DIR__);
require __DIR__ . '/tuya_lib.php';
$dry = in_array('--dry-run', $argv, true);
try {
$r = automation_run($dry);
} catch (Throwable $e) {
$r = ['time' => date('c'), 'fatal' => $e->getMessage()];
}
echo json_encode($r, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(empty($r['errors']) ? 0 : 1);
Isi automation_run() menggabungkan semua logic di atas: baca suhu → hitung jadwal + override → bandingkan dengan status aktual tiap AC → kirim command kalau perlu, dengan throttle (MIN_COMMAND_INTERVAL_SECONDS) agar tidak mengirim command berulang-ulang dalam waktu dekat:
function automation_run($dryRun = false)
{
$out = [
'time' => date('c'),
'dry_run' => $dryRun,
'temp' => null,
'sensor' => 'sensor_02',
'target' => schedule_target(),
'hysteresis' => override_thresholds(),
'decisions' => [],
'errors' => [],
];
$minInterval = (int)cfg('MIN_COMMAND_INTERVAL_SECONDS', '600');
$enabled = filter_var(cfg('CONTROL_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN);
try {
$t = influx_temp('sensor_02', 'value');
$out['temp'] = $t ? $t['temperature'] : null;
$out['temp_time'] = $t ? $t['time'] : null;
} catch (Throwable $e) {
$out['errors'][] = 'influx: ' . $e->getMessage();
return $out;
}
if ($out['temp'] === null) {
$out['errors'][] = 'no temperature data';
return $out;
}
$temp = $out['temp'];
$sched = $out['target'];
$state = state_load();
$override = override_decide($temp, !empty($state['override']));
// desired state = schedule; every AC is forced ON while override is latched
$desired = $sched;
if ($override) {
foreach ($desired as $k => $v) $desired[$k] = true;
}
$devices = ac_devices();
$tk = null;
if (!$dryRun && $enabled) $tk = token();
foreach (['ac2', 'daikin', 'gree'] as $k) {
if (empty($devices[$k]['id'])) continue;
$dec = ['device' => $devices[$k]['name'], 'key' => $k, 'scheduled' => $sched[$k], 'desired' => $desired[$k]];
try {
if ($dryRun) {
$dec['action'] = 'skipped (dry-run)';
$out['decisions'][] = $dec;
continue;
}
$st = ac_status($devices[$k]['id'], $tk);
$dec['current'] = $st['on'];
if ($st['on'] === $desired[$k]) {
$dec['action'] = 'noop';
$out['decisions'][] = $dec;
continue;
}
$last = $state['last_cmd'][$k] ?? 0;
if (time() - $last < $minInterval) {
$dec['action'] = 'throttled (' . (time() - $last) . 's < ' . $minInterval . 's)';
$out['decisions'][] = $dec;
continue;
}
if (!$enabled) {
$dec['action'] = 'control disabled';
$out['decisions'][] = $dec;
continue;
}
ac_set_power($devices[$k]['id'], $desired[$k], $tk);
$state['last_cmd'][$k] = time();
state_save($state);
$dec['action'] = $desired[$k] ? 'turned ON' : 'turned OFF';
} catch (Throwable $e) {
$dec['action'] = 'error';
$out['errors'][] = $k . ': ' . $e->getMessage();
}
$out['decisions'][] = $dec;
}
$out['override_active'] = $override;
@file_put_contents(sys_get_temp_dir() . '/tuya_ac_last.json', json_encode($out, JSON_UNESCAPED_SLASHES));
@chmod(sys_get_temp_dir() . '/tuya_ac_last.json', 0666);
// persist the latched flag (never from a dry-run, which must not mutate state)
if (!$dryRun) {
$state['override'] = $override;
state_save($state);
}
return $out;
}
Setiap pass menghasilkan satu baris log JSON berisi keputusan per-AC (noop, turned ON, turned OFF, throttled, error, atau skipped (dry-run)) — sangat membantu untuk audit/debug tanpa perlu database log tersendiri.
Entry crontab-nya:
* * * * * /usr/bin/php7.4 /var/www/html/tuya-smartir/ac_automation.php >> /var/log/tuya_ac.log 2>&1
Dashboard Web (index.php)
Dashboard-nya murni PHP + HTML + sedikit JS, tanpa library frontend. Bagian pentingnya:
- Baca state — ambil suhu 3 sensor, jadwal hari ini, status tiap AC dari Tuya, dan log automation terakhir.
- Kontrol manual — tombol ON/OFF per AC, dikirim via
fetch()POST, dilindungi CSRF token per-session.
<?php
require __DIR__ . '/tuya_lib.php';
session_start();
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(24));
// ---- manual control (POST) ----
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
try {
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) throw new Exception('Invalid session token');
$key = $_POST['device'] ?? '';
$action = $_POST['action'] ?? '';
$devices = ac_devices();
if (!isset($devices[$key]) || !in_array($action, ['on', 'off'], true)) throw new Exception('Invalid device/action');
if (!filter_var(cfg('CONTROL_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN)) throw new Exception('Control disabled in configuration');
$tk = token();
$res = ac_set_power($devices[$key]['id'], $action === 'on', $tk);
echo json_encode(['ok' => true, 'message' => 'Perintah ' . strtoupper($action) . ' diterima Tuya']);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => $e->getMessage()]);
}
exit;
}
// ---- read status ----
$data = ['temps' => [], 'devices' => [], 'errors' => []];
try {
$data['temps'] = [
influx_temp('sensor_01', 'value'),
influx_temp('sensor_02', 'value'),
influx_temp('sensor_03', 'value'),
];
} catch (Throwable $e) {
$data['errors'][] = $e->getMessage();
}
$data['temps'] = array_values(array_filter($data['temps']));
$data['temp02'] = null;
foreach ($data['temps'] as $t)
if ($t['sensor'] === 'sensor_02') $data['temp02'] = $t['temperature'];
$sched = schedule_target();
$data['schedule'] = $sched;
$data['target'] = (float)cfg('TARGET_TEMP_C', '24');
$data['hysteresis'] = override_thresholds();
$state = state_load();
$data['override'] = override_decide($data['temp02'], !empty($state['override']));
try {
$tk = token();
foreach (ac_devices() as $k => $d) {
if (empty($d['id'])) {
$data['devices'][$k] = ['name' => $d['name'], 'id' => '', 'error' => 'Device ID missing'];
continue;
}
try {
$st = ac_status($d['id'], $tk);
$desired = !empty($sched[$k]);
if ($data['override']) $desired = true;
$data['devices'][$k] = [
'name' => $d['name'],
'id' => $d['id'],
'on' => $st['on'],
'temp' => $st['temp'],
'mode' => $st['mode'],
'scheduled' => !empty($sched[$k]),
'desired' => $desired,
];
} catch (Throwable $e) {
$data['devices'][$k] = ['name' => $d['name'], 'id' => $d['id'], 'error' => $e->getMessage()];
}
}
} catch (Throwable $e) {
$data['errors'][] = $e->getMessage();
}
$last = @json_decode(@file_get_contents(sys_get_temp_dir() . '/tuya_ac_last.json'), true);
$data['last_run'] = is_array($last) ? $last : null;
header('Content-Type: text/html; charset=utf-8');
?>
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="60">
<title>SmartIR AC Dashboard</title>
<style>
* { box-sizing: border-box }
body { margin: 0; background: #f3f6fb; color: #152238; font: 15px system-ui, -apple-system, Segoe UI, sans-serif }
.wrap { max-width: 1080px; margin: 32px auto; padding: 0 18px }
.top { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 10px }
.top h1 { margin: 0; font-size: 26px }
.muted { color: #738096; font-size: 13px }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); gap: 16px }
.card { background: #fff; border: 1px solid #e4e9f1; border-radius: 16px; padding: 18px; box-shadow: 0 5px 18px #1b35500b }
.card h2 { font-size: 17px; margin: 0 0 12px }
.temp { font-size: 32px; font-weight: 700; letter-spacing: -1px }
.badge { display: inline-block; border-radius: 999px; padding: 4px 11px; font-size: 12px; font-weight: 700 }
.on { background: #dcf8e9; color: #147342 }
.off { background: #e9edf3; color: #536174 }
.warn { background: #fff0d9; color: #8a5a00 }
.auto { background: #e3ecff; color: #274690 }
.error { background: #fff0ee; color: #a52e20; padding: 11px; border-radius: 10px; margin: 8px 0; font-size: 13px }
.row { display: flex; justify-content: space-between; margin: 9px 0; font-size: 14px }
.controls { display: flex; gap: 8px; margin-top: 14px }
.btn { border: 0; border-radius: 9px; padding: 9px 16px; font-weight: 700; cursor: pointer; font-size: 13px }
.onbtn { background: #15834f; color: #fff }
.offbtn { background: #e6ebf2; color: #22314a }
.btn:disabled { opacity: .5; cursor: wait }
.notice { margin: 16px 0; padding: 11px 14px; background: #fff8e6; border: 1px solid #f2dfa9; border-radius: 10px; color: #70551d; font-size: 13px }
.foot { font-size: 12px; color: #8190a3; margin-top: 16px }
.sched { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px }
pre { background: #0f172a; color: #cbd5e1; padding: 12px; border-radius: 10px; overflow: auto; font-size: 11px; max-height: 220px }
</style>
</head>
<body>
<main class="wrap">
<div class="top">
<div>
<h1>SmartIR AC</h1>
<div class="muted">Suhu ruang server &amp; kendali AC otomatis</div>
</div>
<div class="muted" style="text-align:right">
Jadwal: Panasonic 06:00–18:00 · Daikin 18:00–06:00<br>
Override: ON &gt; <?=number_format($data['hysteresis']['on'],1)?>°C · OFF ≤ <?=number_format($data['hysteresis']['off'],1)?>°C
</div>
</div>
<?php foreach ($data['errors'] as $err): ?>
<div class="error"><?=htmlspecialchars($err, ENT_QUOTES, 'UTF-8')?></div>
<?php endforeach; ?>
<div class="grid">
<section class="card">
<h2>Suhu ruangan</h2>
<?php if (!$data['temps']): ?>
<div class="muted">Belum ada data suhu</div>
<?php else: foreach ($data['temps'] as $t): ?>
<div class="row">
<div><strong><?=htmlspecialchars($t['sensor'], ENT_QUOTES, 'UTF-8')?></strong></div>
<div class="temp"><?=number_format($t['temperature'], 1)?>°</div>
</div>
<?php endforeach; endif; ?>
<div style="margin-top:8px">
<span class="badge <?=$data['override']?'warn':'auto'?>">
<?=$data['override']?'Override aktif — AC standby dinyalakan':'Normal — mengikuti jadwal'?></span>
</div>
<?php if ($data['temp02'] !== null): ?>
<div class="muted" style="margin-top:8px">Acuan kontrol: sensor_02 = <?=number_format($data['temp02'],1)?>°C</div>
<?php endif; ?>
</section>
<?php foreach ($data['devices'] as $key => $d): ?>
<section class="card">
<h2><?=htmlspecialchars($d['name'], ENT_QUOTES, 'UTF-8')?></h2>
<?php if (isset($d['error'])): ?>
<div class="error"><?=htmlspecialchars($d['error'], ENT_QUOTES, 'UTF-8')?></div>
<?php else: ?>
<span class="badge <?=$d['on']?'on':'off'?>"><?=$d['on']?'ON':'OFF'?></span>
<div class="sched">
<span class="badge auto">Jadwal: <?=$d['scheduled']?'ON':'OFF'?></span>
<span class="badge auto">Target: <?=$d['desired']?'ON':'OFF'?></span>
</div>
<div class="row">
<span class="muted">Setelan suhu AC</span>
<strong><?=htmlspecialchars((string)($d['temp'] ?? '—'), ENT_QUOTES, 'UTF-8')?><?=isset($d['temp'])?'°C':''?></strong>
</div>
<div class="controls">
<button class="btn onbtn" onclick="control('<?=$key?>','on',this)">ON</button>
<button class="btn offbtn" onclick="control('<?=$key?>','off',this)">OFF</button>
</div>
<?php endif; ?>
<div class="foot">ID: <?=htmlspecialchars($d['id'], ENT_QUOTES, 'UTF-8')?></div>
</section>
<?php endforeach; ?>
</div>
<div class="notice">
Otomatisasi dijalankan cron setiap menit. AC standby dinyalakan bila suhu sensor_02 &gt; <?=number_format($data['hysteresis']['on'],1)?>°C,
lalu dimatikan kembali hanya setelah suhu turun ke <?=number_format($data['hysteresis']['off'],1)?>°C atau kurang — rentang
<?=number_format($data['hysteresis']['on'] - $data['hysteresis']['off'],1)?>°C mencegah AC hidup-mati berulang saat suhu mendekati ambang.
</div>
<?php if ($data['last_run']): ?>
<section class="card" style="margin-top:16px">
<h2>Log otomatisasi terakhir</h2>
<div class="muted"><?=htmlspecialchars($data['last_run']['time'] ?? '', ENT_QUOTES, 'UTF-8')?> · suhu <?=htmlspecialchars((string)($data['last_run']['temp'] ?? '—'), ENT_QUOTES, 'UTF-8')?>°C</div>
<pre><?=htmlspecialchars(json_encode($data['last_run']['decisions'] ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8')?></pre>
</section>
<?php endif; ?>
<div class="foot">Status perangkat dari Tuya Cloud API. Halaman ini sebaiknya hanya diakses dari jaringan tepercaya.</div>
</main>
<script>
const csrf = <?=json_encode($_SESSION['csrf'])?>;
async function control(device, action, btn) {
if (!confirm('Kirim perintah ' + action.toUpperCase() + ' ke AC ini?')) return;
document.querySelectorAll('button').forEach(b => b.disabled = true);
try {
const f = new FormData();
f.set('csrf', csrf);
f.set('device', device);
f.set('action', action);
const r = await fetch(location.href, {method: 'POST', body: f});
const j = await r.json();
alert(j.message || 'Selesai');
if (j.ok) location.reload();
} catch (e) {
alert('Permintaan gagal: ' + e);
} finally {
document.querySelectorAll('button').forEach(b => b.disabled = false);
}
}
</script>
</body>
</html>
Pengamanan Akses
Dashboard ini bisa mengirim command fisik ke AC, jadi tidak boleh terbuka ke publik:
- Reverse domain + Basic Auth — diakses lewat domain internal (bukan langsung via IP) yang diproteksi
.htpasswd. - Header keamanan —
X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy: no-referrer. - CSRF token per-session — setiap aksi kontrol (
POST) wajib menyertakan token sesi yang valid; tanpa ini request ditolak. - Blok akses IP langsung — akses lewat IP server (tanpa domain) sengaja diblok di level web server, supaya satu-satunya jalan masuk adalah domain yang sudah terpasang Basic Auth.
- Toggle
CONTROL_ENABLED— command ke Tuya (baik dari dashboard maupun cron) hanya jalan kalau flag initruedi konfigurasi; berguna untuk “mematikan” kemampuan kontrol tanpa menyentuh kode saat sedang testing.
File Konfigurasi (.env)
Semua kredensial dan parameter numerik dipisah total dari kode, dibaca lewat parser .env sederhana (bukan library eksternal):
$GLOBALS['ENV'] = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
list($k, $v) = explode('=', $line, 2);
$GLOBALS['ENV'][trim($k)] = trim(trim($v), "\"'");
}
Contoh struktur .env (nilai asli sudah diganti placeholder — jangan pernah commit/publish file .env berisi kredensial asli):
APP_ENV=production
APP_TIMEZONE=Asia/Jakarta
# Tuya IoT Core project credentials
TUYA_BASE_URL=https://openapi.tuyaus.com
TUYA_ACCESS_ID=__REDACTED__
TUYA_ACCESS_SECRET=__REDACTED__
# Device ID per unit AC (dari Tuya Devices list)
TUYA_AC_DAIKIN_DEVICE_ID=__REDACTED__
TUYA_AC_GREE_DEVICE_ID=__REDACTED__
TUYA_AC_PANASONIC_DEVICE_ID=__REDACTED__
# InfluxDB 2.x
INFLUX_VERSION=2
INFLUX_URL=__REDACTED__
INFLUX_TOKEN=__REDACTED__
INFLUX_ORG=__REDACTED__
INFLUX_BUCKET=__REDACTED__
INFLUX_MEASUREMENT=temperature
INFLUX_LOOKBACK=5m
# Kontrol & hysteresis
CONTROL_ENABLED=true
TARGET_TEMP_C=24
HYSTERESIS_C=1
MIN_COMMAND_INTERVAL_SECONDS=600
AC_MODE=cool
Tooling Debug yang Dipakai Selama Development
Karena signing Tuya & query Flux sulit di-debug langsung dari log produksi, dibuat 7 script kecil read-only — semuanya hanya baca .env, tidak pernah hardcode kredensial, dan tidak mengubah apapun di Tuya/InfluxDB:
test_tuya.php — cek konektivitas Tuya Cloud
<?php
// test_tuya.php - read-only Tuya Cloud connectivity check. Never prints credentials.
$envPath = __DIR__ . '/.env';
if (!is_readable($envPath)) {
fwrite(STDERR, "FAIL: .env missing or unreadable\n");
exit(2);
}
$env = [];
foreach (file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
$base = rtrim($env['TUYA_BASE_URL'] ?? 'https://openapi.tuyaus.com', '/');
$id = $env['TUYA_ACCESS_ID'] ?? '';
$secret = $env['TUYA_ACCESS_SECRET'] ?? '';
if ($id === '' || $secret === '') {
fwrite(STDERR, "FAIL: TUYA_ACCESS_ID or TUYA_ACCESS_SECRET is empty\n");
exit(2);
}
function tuyaReq($method, $path, $id, $secret, $token = '')
{
$t = (string)round(microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
$body = '';
$content = hash('sha256', $body);
$stringToSign = strtoupper($method) . "\n" . $content . "\n\n" . $path;
$sign = strtoupper(hash_hmac('sha256', $id . $token . $t . $nonce . $stringToSign, $secret));
$headers = [
'client_id: ' . $id,
't: ' . $t,
'nonce: ' . $nonce,
'sign_method: HMAC-SHA256',
'sign: ' . $sign,
'lang: en',
];
if ($token !== '') $headers[] = 'access_token: ' . $token;
$ch = curl_init('https://openapi.tuyaus.com' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false) return ['ok' => false, 'error' => 'transport error'];
$json = json_decode($raw, true);
return ['ok' => is_array($json) && ($json['success'] ?? false) === true, 'http' => $http, 'json' => $json];
}
$r = tuyaReq('GET', '/v1.0/token?grant_type=1', $id, $secret);
if (!$r['ok']) {
$code = $r['json']['code'] ?? 'unknown';
$msg = $r['json']['msg'] ?? $r['error'] ?? 'request failed';
echo "Tuya token: FAIL (HTTP " . ($r['http'] ?? 0) . ", code $code, $msg)\n";
exit(1);
}
$token = $r['json']['result']['access_token'] ?? '';
if ($token === '') {
echo "Tuya token: FAIL (empty token response)\n";
exit(1);
}
echo "Tuya token: OK\n";
$devices = [
'Daikin' => $env['TUYA_AC_DAIKIN_DEVICE_ID'] ?? '',
'AC unknown 1' => $env['TUYA_AC_UNKNOWN_1_DEVICE_ID'] ?? '',
'AC unknown 2' => $env['TUYA_AC_UNKNOWN_2_DEVICE_ID'] ?? '',
];
foreach ($devices as $label => $device) {
if ($device === '') {
echo "$label status: SKIP (device ID missing)\n";
continue;
}
$x = tuyaReq('GET', '/v1.0/devices/' . rawurlencode($device) . '/status', $id, $secret, $token);
if ($x['ok']) {
echo "$label status: OK (read-only; " . count($x['json']['result'] ?? []) . " status entries)\n";
} else {
echo "$label status: FAIL (HTTP " . ($x['http'] ?? 0) . ", code " . ($x['json']['code'] ?? 'unknown') . ", " . ($x['json']['msg'] ?? $x['error'] ?? 'request failed') . ")\n";
}
}
inspect_devices.php — dump daftar fungsi & status tiap device
<?php
// inspect_devices.php - dump Tuya functions/status for named devices (read-only debug tool)
$env = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
$id = $env['TUYA_ACCESS_ID'] ?? '';
$secret = $env['TUYA_ACCESS_SECRET'] ?? '';
$base = rtrim($env['TUYA_BASE_URL'] ?? 'https://openapi.tuyaus.com', '/');
function api($path, $token = '')
{
global $id, $secret, $base;
$t = (string)round(microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
$bodyHash = hash('sha256', '');
$stringToSign = "GET\n" . $bodyHash . "\n\n" . $path;
$sign = strtoupper(hash_hmac('sha256', $id . $token . $t . $nonce . $stringToSign, $secret));
$headers = [
'client_id: ' . $id,
't: ' . $t,
'nonce: ' . $nonce,
'sign_method: HMAC-SHA256',
'sign: ' . $sign,
];
if ($token !== '') $headers[] = 'access_token: ' . $token;
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
curl_close($ch);
return json_decode($raw, true);
}
$r = api('/v1.0/token?grant_type=1');
if (!($r['success'] ?? false)) {
echo "Tuya token: FAIL\n";
exit(1);
}
$tk = $r['result']['access_token'];
$targets = [
'Daikin' => 'TUYA_AC_DAIKIN_DEVICE_ID',
'AC2' => 'TUYA_AC_UNKNOWN_2_DEVICE_ID',
];
foreach ($targets as $name => $key) {
$deviceId = $env[$key] ?? '';
echo "[$name]\n";
foreach (['functions', 'status'] as $kind) {
$x = api('/v1.0/devices/' . rawurlencode($deviceId) . '/' . $kind, $tk);
if (($x['success'] ?? false) !== true) {
echo "$kind FAIL (code " . ($x['code'] ?? 'unknown') . ": " . ($x['msg'] ?? '') . ")\n";
continue;
}
echo "$kind: " . json_encode($x['result'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
}
}
debug_flux.php — dump record terbaru per sensor
<?php
// debug_flux.php - dump the latest InfluxDB record per sensor (read-only debug tool)
$env = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
list($k, $v) = explode('=', $line, 2);
$env[$k] = trim(trim($v), "\"'");
}
$url = rtrim($env['INFLUX_URL'], '/');
$org = $env['INFLUX_ORG'];
$bucket = $env['INFLUX_BUCKET'];
$query = 'from(bucket: ' . json_encode($bucket) . ')'
. ' |> range(start: -24h)'
. ' |> filter(fn: (r) => r._measurement == ' . json_encode($env['INFLUX_MEASUREMENT']) . ')'
. ' |> group(columns: ["device_id"])'
. ' |> last()'
. ' |> keep(columns: ["_time", "_field", "_value", "device_id", "location"])';
$ch = curl_init($url . '/api/v2/query?org=' . rawurlencode($org));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $query,
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $env['INFLUX_TOKEN'],
'Content-Type: application/vnd.flux',
'Accept: application/csv',
],
CURLOPT_TIMEOUT => 20,
]);
$result = curl_exec($ch);
curl_close($ch);
echo $result;
list_influx_latest.php — status terbaru semua sensor dalam 24 jam
<?php
// list_influx_latest.php - latest record per sensor over the last 24h (read-only debug tool)
$env = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
$url = rtrim($env['INFLUX_URL'], '/');
$org = $env['INFLUX_ORG'];
$bucket = $env['INFLUX_BUCKET'];
$measurement = $env['INFLUX_MEASUREMENT'] ?? 'temperature';
$field = $env['INFLUX_FIELD'] ?? '';
$flux = 'from(bucket: ' . json_encode($bucket) . ')'
. ' |> range(start: -24h)'
. ' |> filter(fn: (r) => r._measurement == ' . json_encode($measurement) . ')';
if ($field !== '') {
$flux .= ' |> filter(fn: (r) => r._field == ' . json_encode($field) . ')';
}
$flux .= ' |> group(columns: ["device_id"])'
. ' |> last()'
. ' |> keep(columns: ["_time", "_field", "_value", "device_id", "location"])';
$ch = curl_init($url . '/api/v2/query?org=' . rawurlencode($org));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $flux,
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $env['INFLUX_TOKEN'],
'Content-Type: application/vnd.flux',
'Accept: application/csv',
],
CURLOPT_TIMEOUT => 25,
]);
$result = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($result === false) {
echo "request failed: $err\n";
exit(1);
}
echo "HTTP $http; latest records by sensor (last 24h)\n$result\n";
list_influx_measurements.php — daftar measurement di bucket
<?php
// list_influx_measurements.php - list InfluxDB measurement names in the bucket (read-only debug tool)
$env = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
$url = rtrim($env['INFLUX_URL'], '/');
$org = $env['INFLUX_ORG'];
$bucket = $env['INFLUX_BUCKET'];
$query = 'import "influxdata/influxdb/schema"' . "\n"
. 'schema.measurements(bucket: ' . json_encode($bucket) . ')';
$ch = curl_init($url . '/api/v2/query?org=' . rawurlencode($org));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $query,
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $env['INFLUX_TOKEN'],
'Content-Type: application/vnd.flux',
'Accept: application/csv',
],
CURLOPT_TIMEOUT => 20,
]);
$result = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "HTTP $http\n$result\n";
list_influx_sensors.php — daftar distinct device_id
<?php
// list_influx_sensors.php - list distinct device_id tag values in the bucket (read-only debug tool)
$env = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
$url = rtrim($env['INFLUX_URL'], '/');
$org = $env['INFLUX_ORG'];
$bucket = $env['INFLUX_BUCKET'];
$query = 'import "influxdata/influxdb/schema"' . "\n"
. 'schema.tagValues(bucket: ' . json_encode($bucket) . ', tag: "device_id")';
$ch = curl_init($url . '/api/v2/query?org=' . rawurlencode($org));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $query,
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $env['INFLUX_TOKEN'],
'Content-Type: application/vnd.flux',
'Accept: application/csv',
],
CURLOPT_TIMEOUT => 20,
]);
$result = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "HTTP $http\n$result\n";
list_influx_tags.php — daftar tag key yang tersedia
<?php
// list_influx_tags.php - list tag keys available in the bucket (read-only debug tool)
$env = [];
foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim(trim($v), "\"'");
}
$url = rtrim($env['INFLUX_URL'], '/');
$org = $env['INFLUX_ORG'];
$bucket = $env['INFLUX_BUCKET'];
$query = 'import "influxdata/influxdb/schema"' . "\n"
. 'schema.tagKeys(bucket: ' . json_encode($bucket) . ')';
$ch = curl_init($url . '/api/v2/query?org=' . rawurlencode($org));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $query,
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $env['INFLUX_TOKEN'],
'Content-Type: application/vnd.flux',
'Accept: application/csv',
],
CURLOPT_TIMEOUT => 20,
]);
$result = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "HTTP $http\n$result\n";
Catatan kompatibilitas PHP: versi awal 6 dari 7 script di atas memakai
str_contains()danstr_starts_with()— fungsi bawaan PHP 8.0+. Server produksi masih memakai PHP 7.4, jadi semua script tadi langsung crash fatal error begitu dijalankan. Setelah diganti kestrpos($line, '=') === falsedan pengecekan karakter pertama ($line[0] === '#') — yang kompatibel sejak PHP lama — semua script berjalan normal. Ini jadi pengingat penting: selalu cocokkan fitur bahasa yang dipakai dengan versi PHP yang benar-benar jalan di server, jangan asumsi versi terbaru.
Troubleshooting Nyata yang Ditemukan
Beberapa isu konkret yang muncul selama development dan cara memperbaikinya:
- Device ID ketukar / typo — salah satu unit AC (Gree) sempat tidak merespons command karena
TUYA_AC_GREE_DEVICE_IDdi.envtidak sama dengan device ID aslinya di Tuya Cloud (typo saat disalin dari dashboard Tuya). Diperbaiki dengan memverifikasi ulang device ID lewatinspect_devices.phplalu mencocokkan ke.env. - Header
AuthorizationInfluxDB salah format — sempat tertulis literalAuthorization: *** <token>alih-alihAuthorization: Token <token>di satu script debug manual, menyebabkan response “unauthorized” yang membingungkan karena terlihat seperti masalah token padahal hanya typo prefix header. - Fungsi PHP 8.0+ di server PHP 7.4 — seperti dicatat di atas, menyebabkan 6 dari 7 script debug tidak bisa jalan sama sekali sebelum diperbaiki.
- IR AC tanpa DP
powerboolean — command/commandsdengan payload boolean langsung ditolak Tuya (error code2008) untuk device kategoriinfrared_ac; solusinya memakai commandPowerOn/PowerOffsebagai jalur utama, dengan fallback ke DP boolean untuk device non-IR.
Ringkasan
Dengan ~400 baris tuya_lib.php, dashboard tipis, dan satu cron job per menit, tiga unit AC ruang server kini dikendalikan otomatis berdasarkan jadwal + suhu real-time, dengan hysteresis yang mencegah flapping, override manual yang aman (CSRF + toggle enable), dan log JSON per-pass untuk audit. Semua kredensial terisolasi di .env (tidak pernah ikut masuk ke kode), dan akses dashboard dibatasi lewat domain + Basic Auth + blokir akses IP langsung.
Script di atas disalin langsung dari kode produksi, dengan semua kredensial (Tuya client_id/secret, InfluxDB URL/token/org/bucket, device ID) diganti placeholder __REDACTED__ atau dihilangkan dari contoh konfigurasi. Jangan commit/publish .env asli — gunakan contoh di atas sebagai template.