Milesight VS135: Ultra ToF LoRaWAN People Counter

Milesight VS135 Ultra ToF people counter: own ChirpStack decoder framework for line, region and dwell counts plus retail integration.

Milesight VS135
VS135Sensor
LoRaWAN
Class C, OTAA / ABP
Band
EU868 / US915 / AS923 and more
Sensing
2nd-gen ToF depth + AI, no camera images
Counting accuracy
up to 99.8 %
Installation height
2 to 6.5 m (high-ceiling mount)
Power
DC 12 V / PoE (line-powered, no battery)
Operating temperature
-20 to +50 °C
Measurements

What the VS135 measures

Line crossing (in / out)

Up to four counting lines, cumulative people in and out per line.

Regional counting

Up to four regions report the current head count per zone.

Dwell time

Average and maximum dwell time per region for queue analysis.

Period counts

People in and out per reporting period, separate from the running total.

Multi-device stitching

Master and child sensors report a combined count for wide entrances.

Data into your dashboard

Integration

Sensor / controller

Measures or controls in the field and sends LoRaWAN uplinks.

LoRaWAN gateway

Receives the radio packets and forwards them to the server.

ChirpStack

Network server: manages sessions and decodes the payload.

ThingsBoard / Grafana

Dashboards, alarms, rules and reports.

ChirpStack v4 · decodeUplink
function decodeUplink(input) {
  var bytes = input.bytes;
  var data = { lines: {}, regions: {}, dwell: {} };

  // Channel groups (IPSO): each counting line uses a fixed triplet of channels.
  var inChns = [0x03, 0x06, 0x09, 0x0c];
  var outChns = [0x04, 0x07, 0x0a, 0x0d];
  var periodChns = [0x05, 0x08, 0x0b, 0x0e];

  for (var i = 0; i < bytes.length; ) {
    var channel = bytes[i++];
    var type = bytes[i++];

    // Device info on join / power-on: skip with known lengths.
    if (channel === 0xff) {
      if (type === 0x01) { i += 1; }        // protocol version
      else if (type === 0x09) { i += 2; }   // hardware version
      else if (type === 0x16) { i += 8; }   // serial number
      else if (type === 0x1f) { i += 4; }   // firmware version
      else { break; }                       // downlink-response payloads vary
      continue;
    }

    // Line total IN: UINT32 little-endian.
    if (idx(inChns, channel) >= 0 && type === 0xd2) {
      data.lines["line_" + (idx(inChns, channel) + 1) + "_in"] = readUInt32LE(bytes, i);
      i += 4; continue;
    }
    // Line total OUT: UINT32 little-endian.
    if (idx(outChns, channel) >= 0 && type === 0xd2) {
      data.lines["line_" + (idx(outChns, channel) + 1) + "_out"] = readUInt32LE(bytes, i);
      i += 4; continue;
    }
    // Line period IN/OUT: two UINT16 little-endian values.
    if (idx(periodChns, channel) >= 0 && type === 0xcc) {
      var n = idx(periodChns, channel) + 1;
      data.lines["line_" + n + "_period_in"] = readUInt16LE(bytes, i);
      data.lines["line_" + n + "_period_out"] = readUInt16LE(bytes, i + 2);
      i += 4; continue;
    }
    // Region count: four UINT8 head counts.
    if (channel === 0x0f && type === 0xe3) {
      for (var r = 0; r < 4; r++) { data.regions["region_" + (r + 1)] = bytes[i + r]; }
      i += 4; continue;
    }
    // Region dwell time: region id + avg + max (UINT16 little-endian, seconds).
    if (channel === 0x10 && type === 0xe4) {
      var region = bytes[i];
      data.dwell["region_" + region + "_avg"] = readUInt16LE(bytes, i + 1);
      data.dwell["region_" + region + "_max"] = readUInt16LE(bytes, i + 3);
      i += 5; continue;
    }
    // Occlusion / sensor alarm: node id + alarm type.
    if (channel === 0x50 && type === 0xfc) {
      data.alarm = { node: bytes[i + 1], type: bytes[i + 2] };
      i += 3; continue;
    }

    break; // unknown channel: stop, the mapping is deployment-specific
  }
  return { data: data };
}

function idx(arr, v) {
  for (var k = 0; k < arr.length; k++) { if (arr[k] === v) { return k; } }
  return -1;
}
function readUInt16LE(b, i) {
  return ((b[i + 1] << 8) | b[i]) & 0xffff;
}
function readUInt32LE(b, i) {
  return ((b[i + 3] << 24) | (b[i + 2] << 16) | (b[i + 1] << 8) | b[i]) >>> 0;
}

Implemented from the published Milesight byte specification (Communication Protocol / User Guide).

The VS135 payload is configuration-dependent: which counting lines, regions and dwell-time channels appear depends on how you set up the detection lines and zones in the web GUI. Line totals are UINT32 little-endian, region counts are single bytes per zone and dwell times are UINT16 seconds. Master and child sensors use separate channel groups for multi-device stitching. This is a framework, not a drop-in: we confirm the final field mapping against a real uplink from your deployment. The LoRaWAN version is Class C and line-powered. For ThingsBoard the same channel logic goes into an uplink converter.

From the field

Configuration & pitfalls

Draw lines and regions first

Counting lines and regions are configured over the local Wi-Fi web GUI before rollout. The channels in the uplink only exist for the lines and zones you actually define.

Auto height detection

The sensor detects its own installation height. Mount it level and within 2 to 6.5 m, since a tilted or out-of-range mount degrades depth accuracy.

Master and child stitching

For wide entrances, link multiple units. Child sensors report under their own channel group, so the decoder and dashboard must sum master and child counts.

Class C power planning

The LoRaWAN version runs on DC 12 V or PoE and keeps its receive window open. Plan for permanent power, this is not a battery sensor.

Your partner

How merkaio supports your VS135

From sourcing to day-to-day operation, all from one partner on our own European infrastructure.

Pre-staging & provisioning

We configure the VS135, set keys, intervals and alarms, and ship it ready to deploy.

Own decoder

Payload codec for ChirpStack v4 and ThingsBoard, implemented from the Milesight specification.

Dashboard integration

Data lands in your ThingsBoard or Grafana, with alarms and reports.

Operations & monitoring

We run the LoRaWAN stack and dashboards on European infrastructure, you just use the data.

Frequently asked questions

Yes. The LoRaWAN version is a standard Class C device, no Milesight gateway or cloud is required. You add the codec to the device profile and provision it via OTAA.
Yes, implemented from the published Milesight byte specification. Because the payload depends on your line and region setup, we deliver a decoder framework and confirm the exact field mapping against a real uplink. The same logic goes into a ThingsBoard uplink converter.
The uplink only carries the counting lines, regions and dwell-time zones you define in the web GUI, each on its own channel group. The decoder reads whatever channels are present, so we validate it against your deployment rather than shipping a fixed drop-in.
Yes. It uses 2nd-generation ToF depth sensing with an AI algorithm and does not store camera images, so it provides anonymous people-counting analytics without capturing identifiable footage.
Line-crossing counts (people in and out) on up to four lines, current head count in up to four regions, and average and maximum dwell time per region for queue and occupancy analysis.
No. The LoRaWAN version is line-powered over DC 12 V or PoE and operates as a Class C device, so it needs permanent power rather than a battery.
From the same series

Related devices

Let's discuss your infrastructure. Digital and on-site.

Whether it's IoT platform development, hardware selection, managed hosting for ChirpStack, ThingsBoard, Grafana or NetBird VPN, or migration from a self-hosted setup - we'll find the right solution for your use case. Book a free 30-minute consultation, no commitment required.

Timo Wevelsiep

Your contact

Timo Wevelsiep

Founder, merkaio

15 minutes, no commitment, directly with Timo.

By submitting, you agree to our Privacy Policy.

Decoder for ChirpStack v4. merkaio is an independent integrator and is not affiliated with Milesight.