Skip to main content

Petrainer Shock Collar

Introduction

This document describes a way to control the Petrainer PET998DRB Dog Training Collar over 433Mhz-Band Radio Control.

Credit to XMPPWocky for the proof of concept control code.

The information in this document was taken from this code initially and extended with information found while coding GoToShock.

RF protocol

The Petrainer shockers listens to ASK on a 433Mhz carrier wave. While the original version of this document said OOK, it understands data sent with ASK transmitters like the FS1000A, so it more likely understands OOK "by accident".

Bits are encoded using PWM with two different duty cycles, 25% for a 0 and 75% for a 1. The total length of each bit is 1ms.

protocol bitpwm duty cycleon-timeoff-time
025%250µs750µs
175%750µs250µs

Each message send is preceded by a longer pulse of 1.25 to 1.5ms, most likely to allow the receiver to set its gain.

Digital protocol

Messages are always 42 bit long and always consist of the same fields. There is no field to signal the duration of a given command, instead, the messages are just repeated for as long as the command should be done (e.g. the remote just sends the same message over and over again until the button is released).

bitsfielddescription
2headerconstant 01
4channelsee below
3commandsee below
17unknownnot yet reverse-enbyneered - most likely contains a more-or-less unique ID for the remote that sent the message
7intensityhow much to shock, capped by the shockers at 100
3command checkcheck value for the command
4channel checkcheck value for the channel
2footerconstant 00

Check values

Some fields have a kinda "checksum" at a later place in the message. The purpose of this is most likely to have something not done by other protocols on this RF band, making sure your shockers are not triggered by someone turning on their remote controlled outlet.

To build those check values, you take the original bit sequence, flip all the bits and reverse their order. Examples:

  • original bits: 001, all flipped: 110, order reversed: 011
  • original bits: 010, all flipped: 101, order reversed: 101
  • original bits: 0000, all flipped: 1111, order reversed: 1111
  • original bits: 1110, all flipped: 0001, order reversed: 1000

Channels

The original remote can send on two different channels, called channels 1 and 2 by it. The bit encoding for them is 0000 for channel 1 and 1110 for channel two, meaning the protocol might understand more such channels, but this was not tested yet.

Commands

Three commands are supported by the original remote and shockers. With the given number of bits, the protocol could, in theory, support more.

While the intensity field is sent in every message, not all commands make use of it - you can set it to whatever value for those.

commandbitsintensity?description
Zap/Shock001yesIssues a static shock of specified intensity.
Vibrate010yesMake the shock collar vibrate, great for "announcing" an incoming shock - and then just not sending one after all :>
Beep100noBeep.

Full example with annotations

Here is a full message ("channel 2: shock with intensity 38%") with annotations:

01  1110  001  00101110001010110  0100110  011  1000  00
|- |--- |-- |---------------- |------ |-- |--- |-
| | | | | | | \- footer, always 00
| | | | | | |
| | | | | | \------- checksum for the channel
| | | | | |
| | | | | \------------ checksum for the command
| | | | |
| | | | \--------------------- unsigned int for intensity,
| | | | 38 here
| | | |
| | | \---------------------------------------- unknown usage :(
| | |
| | \--------------------------------------------- command, "zap"/"shock" here
| | (we should decide on a name)
| |
| \--------------------------------------------------- channel, here "channel 2"
|
\------------------------------------------------------- header, always "01"

Implementation in Go

Supporting output via Raspberry Pi GPIOs and probably other drivers in the future. Meant to built a kinda PiShock experience but in OpenSource, can also be used as a library in other projects:

Python script

This code talks to an RFCat dongle. Other devices like a YardStickOne likely work with minimal modification.
DIY solutions like this might also work.

The ported python3 script, which should work, provided rflib works with python3.

"""
Module for connecting to a Petrainer Shock Collar and sending commands

This module implements a framework to send On-Off-Key-encoded messages
over radio using an rfcat dongle, and a class that controls the collar's shock function.

Credit to XMPPWocky (https://twitter.com/xmppwocky) for the proof of concept control code.
It was modified by definite_purple to work with python3.
Although she was not able to test it, because she doesn't have the hardware.

rflib can be obtained here: https://bitbucket.org/eviljonny/rflib
bitstring over pypi or here: https://github.com/scott-griffiths/bitstring
"""


import bitstring
import rflib
# import binascii

MHZ = 1000*1000

_COLLAR_BAUD_PWM = 4200 # The baud of the rc
_COLLAR_BAUD = _COLLAR_BAUD_PWM/4 # message bits get encoded to 4 radio bits
_COLLAR_FREQ = 434*MHZ


def _pwm_to_raw(pwm):
"""decodes messages received from the control unit"""
raw = bitstring.BitStream()
while True:
try:
nybble = pwm.read(4)
if nybble.bin == "1110":
raw += bitstring.Bits("0b1")
elif nybble.bin == "1000":
raw += bitstring.Bits("0b0")
elif nybble.bin == "0000":
pass # radio silence. No info
else:
print(nybble)
print(nybble.bin)
raise ValueError("bad nybble")

except bitstring.ReadError:
break

return raw


def _raw_to_pwm(raw):
"""encodes messages in preparation to sending them to the collar"""
pwm = bitstring.BitStream()
for bit in raw.bin:
if bit == "0":
pwm += bitstring.Bits("0b1000")
else:
pwm += bitstring.Bits("0b1110")

return pwm


def configure_rfcat(d):
"""configures the rfcat dongle to the collar's language"""
d.setFreq(_COLLAR_FREQ)
d.setMdmModulation(rflib.MOD_ASK_OOK)
d.setMdmDRate(_COLLAR_BAUD_PWM)


def tx_raw(d, raw, repeat=8):
"""encodes message, precedes pulse, pads with silcence, sends 8x

adds 00000000000000011111 in front of the encoded part
(silence, then a pulse)
and 000000000000000000000000 behind it.
(silence)

I don't know exactly why the signal goes high for five pwm-bits
before each transmission.
It is likely there to allow the receiver to set its gain."""
pwm = _raw_to_pwm(raw)
tosend = bitstring.BitString(bytes=b"\x00\x01\xf0", length=(20)) \
+ pwm + bitstring.Bits(bytes=b"\x00\x00\x00")
# print(tosend.hex)
d.RFxmit(tosend.tobytes(), repeat=repeat)


def zap(d, intensity):
"""modifies a template with the shock intesity, and proceeds to transmit"""
assert intensity <= 100
assert intensity >= 0

template = bitstring.BitString(
bin="010000001101110100101011100101000011111100")
template[25:32] = bitstring.Bits(uint=intensity, length=7)
tx_raw(d, template)


class ShockCollar:
"""class for the shock collar"""
def __init__(self):
d = rflib.RfCat()
configure_rfcat(d)
self.d = d

def shock(self, intensity=1.0):
"""accepts a number 0 <= intensity <= 1 and sends the shock command"""
intensity_int = int(intensity*100.0)

zap(self.d, intensity_int)

The original python2 script

import rflib
import binascii
import bitstring

MHZ=1000*1000

_COLLAR_BAUD_PWM=4200
_COLLAR_BAUD=_COLLAR_BAUD_PWM/4
_COLLAR_FREQ=434*MHZ

def _pwm_to_raw(pwm):
raw = bitstring.BitStream()
while True:
try:
nybble = pwm.read(4)
if nybble.bin == "1110":
raw += bitstring.Bits("0b1")
elif nybble.bin == "1000":
raw += bitstring.Bits("0b0")
elif nybble.bin == "0000":
pass #ew
else:
print nybble
print nybble.bin
raise ValueError("bad nybble")

except bitstring.ReadError:
break

return raw

def _raw_to_pwm(raw):
pwm = bitstring.BitStream()
for bit in raw.bin:
if bit == "0": pwm += bitstring.Bits("0b1000")
else: pwm += bitstring.Bits("0b1110")

return pwm


def configure_rfcat(d):
d.setFreq(_COLLAR_FREQ)
d.setMdmModulation(rflib.MOD_ASK_OOK)
d.setMdmDRate(_COLLAR_BAUD_PWM)

def tx_raw(d, raw, repeat=8):
pwm = _raw_to_pwm(raw)
tosend = bitstring.BitString(bytes="\x00\x01\xf0", length=(20)) + pwm + bitstring.Bits(bytes="\x00\x00\x00")
# print tosend.hex
d.RFxmit(tosend.tobytes(), repeat=repeat)

def zap(d, intensity):
assert intensity <= 100
assert intensity >= 0

template=bitstring.BitString(bin="010000001101110100101011100101000011111100")
template[25:32] = bitstring.Bits(uint=intensity, length=7)
tx_raw(d, template)


class ShockCollar:
def __init__(self):
d = rflib.RfCat()
configure_rfcat(d)
self.d = d
def shock(self, intensity=1.0):
intensity_int = int(intensity*100.0)

zap(self.d, intensity_int)