Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,26 @@

This script allows you to interact with a Neewer GL1 LED panel over your network. You must configure the panel to connect to your wifi network for this script to work.

The Neewer GL1 is a lower cost alternative to an El Gato Key Light. It supports Wifi (configured via mobile app). I reverse engineered the protocol between my computer and the light and found that the light listens for commands over UDP on port 5052. As far as I can tell, the light does not return any data such as current state.
The Neewer GL1 is a lower cost alternative to an El Gato Key Light. It supports Wifi (configured via mobile app). Out of the box the light broadcasts its own SSID for setup (e.g. **ATK-625BFB**); you connect to that from the Neewer app to tell the light your home network. I reverse engineered the protocol between my computer and the light and found that the light listens for commands over UDP on port 5052. As far as I can tell, the light does not return any data such as current state.

By being able to control the light using a script, you can integrate controls with something like a Stream Deck, which is how I use it.

As of Version 2.0.0, you no longer need to keep the Neewer Live application running for it to work. In any case, the Neewer Live app on windows is a raging dumpster fire. If for whatever reason the app crashes, or your computer crashes, the app will stop working, because the `DeviceInfo.xml` and/or `UserInfo.xml` file(s) will be corrupted. That's because the executable is constantly updating those files, *and* requires them for operation. The application does not self-heal those files once they're corrupted. You basically need to back up those files and copy them back in when something goes wrong.


In order to use this script, you basically need to know the IP adddress of the light, which you can get after you configure the light using the Neewer mobile app. You will also need to know the IP address of your computer, which is easy enough.
In order to use this script, you need either the light’s IP address or hostname (`-h`) or its MAC address (`-m`). You can get the IP after configuring the light with the Neewer mobile app (though not within the app), or from your router or Wireshark. Using `-m` (MAC) looks up the current IP from your ARP table, so the light’s IP can change (e.g. DHCP) and the script still finds it—as long as something has talked to the light recently (this script or the Neewer app). You will also need the IP address of your computer for the handshake. If you don't pass `-I`, the script tries to detect it (first non-loopback IPv4). If that fails (e.g. multiple interfaces or VPN), set it explicitly with `-I` (e.g. `ipconfig getifaddr en0` on macOS, or your router's client list).

## Parameters

```
-h, --host [required] ip or hostname
-I, --client_ip [char] your computer's IP. If you don't provide it, the script will try to guess your IP (first one it finds)
-h, --host [char] light IP or hostname (use -h OR -m)
-m, --mac [char] light MAC address (e.g. 08:F9:E0:62:5B:FB ). Resolves IP from your ARP table so the light’s IP can change (DHCP). Use -h OR -m.
-I, --client_ip [char] your computer's IP. If you don't provide it, the script guesses (first non-loopback IPv4). If detection fails, you must set it with -I (e.g. -I 192.168.178.165).
-H, --hex
-p, --power [on,off]
-b, --brightness (requires -t) 1-100
-t, --temperature (requires -b) 29-70
-d, --delay [int] in milliseconds. Default is 500. YMMV if you shorten the delay. The script may run faster, but may not execute.
-b, --brightness 1-100 (requires -t when used)
-t, --temperature 29-70 (requires -b when used; 50 = 5000K)
-d, --delay [int] in milliseconds. Default is 50. YMMV if you shorten the delay. The script may run faster, but may not execute.
```

You can generally mix and match the parameters as required, with exceptions:
Expand Down Expand Up @@ -55,12 +56,23 @@ node index.mjs -h 192.168.1.236 -p on -b 10 -t 33 -I 192.168.1.100 -d 400
# turn light off, script guesses your IP
node index.mjs -h 192.168.1.236 -p off

# using hostname (e.g. neewergl1pro from router/DHCP)
node index.mjs -h neewergl1pro -p on
node index.mjs -h neewergl1pro -p off -b 50 -t 33

# set brightness to 10%, temperature to 3300k (light must be turned on first)
node index.mjs -h 192.168.1.236 -b 10 -t 33

# set brightness to 10%, temperature to 5000k using manual hex
node index.mjs -h 192.168.1.236 -H 800503020a32c6

# use MAC so the light’s IP can change (resolved from ARP)
node index.mjs -m 08:F9:E0:62:5B:FB -p on -I 192.168.178.165
node index.mjs -m 08:F9:E0:62:5B:FB -p off

# set brightness and temperature (both required)
node index.mjs -h 192.168.1.236 -b 50 -t 33

```

## Protocol Details
Expand All @@ -74,22 +86,22 @@ I'm not sure how the Neewer Live app scans the network for the lights, but to co

To reverse engineer the protocol, you can watch traffic between your computer and the light using Wireshark.

You'll notice that there's an initializing handshake between Neewer Live and your light, where it sends a message three times, and then the app sends a constant stream of short heartbeat messages afterwards.
You'll notice that there's an initializing handshake between Neewer Live and your light, where it sends a message three times, the light replies with `80:03` to accept the handshake, and then the app can send commands. This script does the same: it sends three handshake messages, waits for the light's `80:03` reply, then sends power/brightness/temperature commands on the same socket.

The light never sends any information back, so if you change your light settings manually, your light will be out of sync with the app's state for the light.
The light does not report status (e.g. current brightness), so if you change settings manually on the light, the app/script has no way to know. It does send a brief `80:03` reply to accept the handshake.

I eventually realized that the command structure works like this:
The command structure works like this:

1. Your computer sends three UDP hex messages containing your computer's IP in succession.
2. You can send any command or a heartbeat to the light to keep the window for the light to receive commands from your computer's IP open.
3. If no heartbeat command is received within a certain time window, then any further commands are rejected unless you resend the commands in #1.
1. Your computer sends three UDP handshake messages (containing your computer's IP) in succession. The light replies with `80:03` when it accepts the handshake.
2. After that, you can send power, brightness, or temperature commands on the same socket. The script waits for `80:03` before sending these.
3. If no command is received within a certain time window, the light may close the session and you must repeat the handshake (steps 1–2).

There is a finite time window for a command to be received. If it's too fast or too slow, then the commands will be rejected. I could not be bothered to figure out this exact timing (but it is 4-5 times per second), as I only care that the script works for my uses. I have included an overridable delay between issuing UDP requests, defaulted to 500ms. I have found setting it shorter can result in faster response times for the light, but missed commands too. YMMV.
There is a finite time window for a command to be received. If it's too fast or too slow, then the commands will be rejected. I could not be bothered to figure out this exact timing (but it is 4-5 times per second), as I only care that the script works for my uses. I have included an overridable delay between issuing UDP requests, defaulted to 50ms. I have found setting it shorter can result in faster response times for the light, but missed commands too. YMMV.


### Commands

The handshake/wakeup command looks like this: `80021000000d3139322e3136382e312e3130382e` for client IP address 192.168.1.108. The structure of the message is `80 02 10 00 00 0d [ip ascii to hex]`, terminated by `2e`
The handshake/wakeup command has the structure `80 02 12 00 00 0f [your IP as 15 ASCII chars, each as hex] [checksum]`, where the checksum is the low byte of the sum of all preceding bytes (e.g. for IP 192.168.1.108 the payload is the ASCII bytes of that string as hex, plus the checksum byte).

To turn on the light, you send the hexadecimal code `800502010189` over UDP.
To turn off the light, you send the hexadecimal code `800502010088` over UDP.
Expand Down
176 changes: 135 additions & 41 deletions index.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// import dgram from 'dgram'
// dgram with promises
var PORT, command_delay, command_queue, convertIp, guessIp, hexify, options, parse_and_execute, presets, send;
var PORT, buildHandshake, command_delay, command_queue, guessIp, hexify, normalizeMac, options, parse_and_execute, presets, resolveMacToIp, send;

import {
DgramAsPromised
Expand All @@ -16,14 +16,18 @@ import program from 'commander';

import os from 'os';

import {
execSync
} from 'child_process';

PORT = 5052;

presets = {
"on": "800502010189",
"off": "800502010088"
};

program.version("Neewer GL1 Key Light Control 2.0").option("-h, --host [char]").option("-H, --hex [char]").option("-I, --client_ip [char]").option("-p, --power [off/on]").option("-b, --brightness [int]").option("-t, --temperature [int]").option("-d, --delay [int]").parse(process.argv);
program.version("Neewer GL1 Key Light Control 2.0").option("-h, --host [char]").option("-m, --mac [char]", "light MAC address (e.g. 08:F9:E0:62:5B:FB); resolves IP from ARP so IP changes are OK").option("-H, --hex [char]").option("-I, --client_ip [char]").option("-p, --power [off/on]").option("-b, --brightness [int]").option("-t, --temperature [int]").option("-d, --delay [int]").parse(process.argv);

// Default command prints out a list of ports
program.parse();
Expand All @@ -32,47 +36,122 @@ options = program.opts();

command_queue = [];

command_delay = 500;
command_delay = 50;

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

send = async function(host, port) {
var bytes, client, closed, hexCommand, message;
var bytes, client, hexCommand, message;
if (command_queue.length === 0) {
console.log("Command queue is empty! Done.");
return;
}
client = DgramAsPromised.createSocket('udp4');
if (command_queue.length > 0) {
hexCommand = command_queue.shift();
message = new Buffer.from(hexCommand, "hex");
bytes = (await client.send(message, 0, message.length, port, host)); //, (err, bytes) ->
console.log(chalk.blue(`Sent command [${hexCommand}] (${bytes} bytes) to ${host}:${port}`));

closed = (await client.close());
// console.log "Connection closed. Going to next in queue."
// prevent commands from being issued too quickly
return send.delay(command_delay, host, port);
} else {
return console.log(chalk.green("Command queue is empty! Done."));
await client.bind(port, '0.0.0.0');

const rawSocket = client.socket;
const waitFor8003Reply = () =>
new Promise((resolve) => {
const onMessage = (msg) => {
if (msg.length >= 2 && msg[0] === 0x80 && msg[1] === 0x03) {
rawSocket.off("message", onMessage);
resolve();
}
};
rawSocket.on("message", onMessage);
});

try {
// Send handshake packets (first 3 in queue) with 50ms between each
for (var i = 0; i < 3 && command_queue.length > 0; i++) {
hexCommand = command_queue.shift();
message = Buffer.from(hexCommand, "hex");
await client.send(message, 0, message.length, port, host);
console.log(`Sent handshake [${hexCommand}] to ${host}:${port}`);
if (i < 2) await delay(command_delay);
}

// Wait for light's 80:03 reply before sending power/other commands
await waitFor8003Reply();
console.log("Received 80:03 reply from light.");

// Send remaining commands (power, brightness/temp, etc.) on same socket
while (command_queue.length > 0) {
hexCommand = command_queue.shift();
message = Buffer.from(hexCommand, "hex");
bytes = await client.send(message, 0, message.length, port, host);
console.log(`Sent message [${hexCommand}] (${bytes} bytes) to ${host}:${port}`);
await delay(command_delay);
}
} finally {
await client.close();
console.log("Connection closed. Done.");
}
};

convertIp = function(ip) {
var ascii, hexified, segments;
segments = ip.split(".");
ascii = segments.map;
hexified = Array.from(ip).map(function(char, index) {
return char.charCodeAt(0).toString(16);
// Same handshake as index.mjs: 80 02 12 00 00 0f [IP 15 chars as hex] [checksum = sum of all bytes & 0xff]
buildHandshake = function(ip) {
var all, checksum, header, headerHex, ipBytes, ipHex, sum;
header = [0x80, 0x02, 0x12, 0x00, 0x00, 0x0f];
ipBytes = Array.from(ip).map(function(c) {
return c.charCodeAt(0);
});
return hexified.join("");
all = header.concat(ipBytes);
sum = all.reduce(function(a, b) {
return a + b;
}, 0);
checksum = (sum & 0xff).toString(16).padStart(2, "0");
headerHex = header.map(function(b) {
return b.toString(16).padStart(2, "0");
}).join("");
ipHex = Array.from(ip).map(function(c) {
return c.charCodeAt(0).toString(16);
}).join("");
return `${headerHex}${ipHex}${checksum}`;
};

guessIp = function() {
var addresses, nics;
var addresses, nics, ref;
nics = os.networkInterfaces();
addresses = [];
Object.keys(nics).forEach(function(key) {
return addresses.push(nics[key]);
});
addresses = addresses.flatten().filter({
family: "IPv4"
addresses = addresses.flatten().filter(function(addr) {
return addr.family === "IPv4" && addr.address !== "127.0.0.1";
});
return addresses.first().address;
return (ref = addresses.first()) != null ? ref.address : void 0;
};

normalizeMac = function(mac) {
var hex = mac.toLowerCase().replace(/[^a-f0-9]/g, '');
return hex.padStart(12, '0');
};

resolveMacToIp = function(mac) {
var i, ip, len, line, lineMac, match, normalized, output, ref;
normalized = normalizeMac(mac);
try {
output = execSync('arp -a', {
encoding: 'utf8'
});
} catch (error) {
return null;
}
ref = output.split('\n');
for (i = 0, len = ref.length; i < len; i++) {
line = ref[i];
// macOS: ? (192.168.178.88) at 08:F9:E0:62:5B:FB on en0
match = line.match(/\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:.-]+)/);
if (match) {
ip = match[1];
lineMac = normalizeMac(match[2]);
if (lineMac === normalized) {
return ip;
}
}
}
return null;
};

hexify = function(brightness, temperature) {
Expand All @@ -87,24 +166,38 @@ hexify = function(brightness, temperature) {
};

parse_and_execute = async function(options) {
var initCommand, ipAddress;
if (options.host != null) {
var host, initCommand, ipAddress;
host = options.mac ? resolveMacToIp(options.mac) : options.host;
if (options.mac && !host) {
console.log(chalk.red(`MAC ${options.mac} not found in ARP table. Use the Neewer app or ping the light once so it appears.`));
return;
}
if (!host) {
console.log(chalk.red("Provide -h/--host (light IP) or -m/--mac (light MAC) to run."));
return;
}
if (host != null) {
if (options.mac != null) {
console.log(chalk.green(`Resolved ${options.mac} -> ${host}`));
}
if (options.client_ip != null) {
ipAddress = options.client_ip;
console.log(chalk.green(`Using ${ipAddress} as the local IP address`));
} else {
ipAddress = guessIp();
if (!ipAddress) {
console.log(chalk.red("Could not determine your computer's IP (no non-loopback IPv4). Use -I to set it (e.g. -I 192.168.178.165)."));
return;
}
console.log(chalk.yellow(`client_ip not provided, using ${ipAddress} as the local IP address`));
}
if (options.delay != null) {
command_delay = options.delay;
}
console.log(`Command delay set to ${command_delay}ms`);
//init command
initCommand = `80021000000d${convertIp(ipAddress)}2e`;
// console.log initCommand
initCommand = buildHandshake(ipAddress);
command_queue.append([initCommand, initCommand, initCommand]);
console.log(`${chalk.yellow("Light Host:")} ${options.host}:${PORT}`);
console.log(`${chalk.yellow("Light Host:")} ${host}:${PORT}`);
if (options.hex != null) {
console.log(`${chalk.red("Hex Override:")} ${options.hex}`);
// send options.host, PORT, options.hex
Expand All @@ -124,16 +217,17 @@ parse_and_execute = async function(options) {
console.log(chalk.red(`Invalid power state '${options.power.toLowerCase()}'. Valid states are 'on' and 'off' only.`));
}
}
if ((options.brightness != null) && (options.temperature != null)) {
console.log(chalk.yellow(`Set brightness to ${options.brightness}% and temperature to ${options.temperature}00K`));
command_queue.append(hexify(options.brightness, options.temperature));
} else {
console.log(chalk.red("When setting brightness or temperature, BOTH parameters are required."));
if ((options.brightness != null) || (options.temperature != null)) {
if ((options.brightness != null) && (options.temperature != null)) {
console.log(chalk.yellow(`Set brightness to ${options.brightness}% and temperature to ${options.temperature}00K`));
command_queue.append(hexify(options.brightness, options.temperature));
} else {
console.log(chalk.red("When setting brightness or temperature, BOTH parameters are required."));
return;
}
}
}
return (await send(options.host, PORT));
} else {
return console.log(chalk.red("No options provided. Did not run."));
return (await send(host, PORT));
}
};

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading