first upload
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
Beepy: sync BPC-enabled watches and clocks from Python 3
|
||||||
|
--------------------------------------------------------
|
||||||
|
This is a Python emulator of the Chinese BPC time broadcasting station signal
|
||||||
|
for syncing longwave-enabled watches and clocks that support this station.
|
||||||
|
With the help of any sort of loop antenna (or even headphones or speakers), it
|
||||||
|
allows you to adjust your watch without having to be close to the Chinese
|
||||||
|
signal. This program follows the BPC timecode specification and modulation
|
||||||
|
methods used by the oldest desktop applications for this very purpose, and
|
||||||
|
transmits on 13700 Hz, whose 5th harmonic is the reference signal frequency,
|
||||||
|
68.5 KHz, but the base frequency here is within the spectrum supported by any
|
||||||
|
consumer-grade audio hardware.
|
||||||
|
|
||||||
|
== Dependencies ==
|
||||||
|
|
||||||
|
Beepy depends on PyAudio (>=0.2.14) and ntplib (>=0.4.0). Just install them
|
||||||
|
by running pip install -r requirements.txt from the project directory. Note
|
||||||
|
that installing PyAudio will also pull its PortAudio (>=v19) dependency.
|
||||||
|
|
||||||
|
The program has been primarily tested on Python 3.10.
|
||||||
|
|
||||||
|
== Usage ==
|
||||||
|
|
||||||
|
Beepy can be run like this:
|
||||||
|
|
||||||
|
python bee.py [-t duration] [-d delta] [-s ntp_server] [-n ntp_version] \
|
||||||
|
[-o tz_offset] [-r sample_rate]
|
||||||
|
|
||||||
|
All parameters are optional here:
|
||||||
|
|
||||||
|
* -h: display help screen
|
||||||
|
* -t: transmission duration in minutes (default 30)
|
||||||
|
* -d: manual delta correction in milliseconds (default 0, see below)
|
||||||
|
* -o: timezone offset from UTC (in hours, default 9, see below)
|
||||||
|
* -r: transmission sampling rate (in Hz, default 48000, only change if this
|
||||||
|
fails to work)
|
||||||
|
* -s: specify NTP server to fetch time from (if no server is specified, then
|
||||||
|
local system time is used)
|
||||||
|
* -n: specify NTP protocol version to use, default is 4, good for most cases
|
||||||
|
|
||||||
|
After running the command, you must enter the synchronization mode on your
|
||||||
|
watch/clock (making sure that BPC is selected if it's multiband) and put it
|
||||||
|
close enough to your (improvised) loop antenna, headphones or speakers. The
|
||||||
|
script will fetch the UTC time according to your source, apply the TZ offset,
|
||||||
|
then the manual delta offset and then will attempt to start the transmission
|
||||||
|
from the closest second. The TZ offset is set to +8 hours by default because
|
||||||
|
most BPS-enabled watches/clocks expect the BST time to be sent in order to
|
||||||
|
then apply their own timezone correction according to your settings. If your
|
||||||
|
watch/clock doesn't have such correction, you can always use this -o flag to
|
||||||
|
zero out this offset (with -o 0) and transmit the local time directly onto it.
|
||||||
|
In case your equipment, software or time source server introduce any delay to
|
||||||
|
the synchronization process, you can add a constant delta (in milliseconds)
|
||||||
|
with the -d flag.
|
||||||
|
|
||||||
|
After the synchronization is successful, you can press the
|
||||||
|
Ctrl+C combination or wait until the entire sequence (which is 30 minutes long
|
||||||
|
by default, adjustable with -t flag) gets transmitted.
|
||||||
|
|
||||||
|
== FAQ ==
|
||||||
|
|
||||||
|
- How is this even possible?
|
||||||
|
|
||||||
|
To put it simply, to emit any audio signal, electricity has to travel through
|
||||||
|
many wires and coils. This inevitably creates electromagnetic interference. If
|
||||||
|
we send the signal of a particular constant frequency with enough intensity
|
||||||
|
through audio circuits, this interference will turn into radio emission in the
|
||||||
|
longwave spectrum, which is exactly what we need for syncing radio-controlled
|
||||||
|
clocks and watches. This emission is too weak to cause any harm outside but
|
||||||
|
enough to be received by the watch or clock several centimeters apart.
|
||||||
|
|
||||||
|
- Which watches/clocks has this been tested on?
|
||||||
|
|
||||||
|
Some Casio models, including Casio GW-B5600BC, GMW-B5000D and GW-5000U.
|
||||||
|
|
||||||
|
- Is my particular watch/clock model supported?
|
||||||
|
|
||||||
|
As long as it can receive BPC signal and you know how to make it do this, it
|
||||||
|
is automatically supported by Beepy. At this point, I can surely say that if
|
||||||
|
anything goes wrong, it's not the fault of your watch or your emulator, but
|
||||||
|
something in between: audio setup, antenna setup or the placement of the watch
|
||||||
|
relative to the antenna. It might take some trial and error and a great deal
|
||||||
|
of patience to make sure everything works as expected.
|
||||||
|
|
||||||
|
For most digital Casio models, you can force BPC reception by entering one
|
||||||
|
of the test menus: press and hold first Light, then Receive/Set and then Mode
|
||||||
|
button. Scroll through with the Receive button to ensure that "B 01" is on the
|
||||||
|
screen, then start the reception process with the Light button. You should get
|
||||||
|
a "BOK" message if the process is successful, or "BNG" if unsuccessful.
|
||||||
|
|
||||||
|
- Why create an alternative to JJY.py?
|
||||||
|
|
||||||
|
In some conditions, multiband Casio watches are proven to sync faster with BPC
|
||||||
|
than with JJY40. Beepy doesn't replace JJY.py as the JJY40 signal is supported
|
||||||
|
by much more models. But you can try out Beepy if the JJY reception takes too
|
||||||
|
long.
|
||||||
|
|
||||||
|
At some time in the future, JJY.py and Beepy might get united into a single
|
||||||
|
time synchronization utility.
|
||||||
|
|
||||||
|
- Are there still any plans for implementing other longwave time protocols?
|
||||||
|
|
||||||
|
Maybe. DCF77 and WWVB are of the primary interest.
|
||||||
|
|
||||||
|
== Credits ==
|
||||||
|
|
||||||
|
Created by Luxferre in 2024. Released into public domain with no warranties.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Beepy: a Python standalone script
|
||||||
|
# to synchronize time on BPC-enabled wristwatches via headphones
|
||||||
|
# Depends on pyaudio and ntplib
|
||||||
|
# Created by Luxferre in 2024, released into public domain
|
||||||
|
|
||||||
|
import pyaudio, ntplib, math, array, time, datetime
|
||||||
|
|
||||||
|
OP_FREQ = 68500/5 # emitted frequency, Hz
|
||||||
|
|
||||||
|
# parity calculation helper
|
||||||
|
def calc_parity(vals):
|
||||||
|
i = 0
|
||||||
|
for val in vals:
|
||||||
|
i ^= (val & 1) ^ ((val >> 1) & 1)
|
||||||
|
return i
|
||||||
|
|
||||||
|
# internal time representation from unix time
|
||||||
|
# only fetches the fields necessary for BPC implementation
|
||||||
|
def intreptime(unixtm):
|
||||||
|
res = {}
|
||||||
|
tm = time.gmtime(unixtm)
|
||||||
|
res['year'] = tm.tm_year % 100
|
||||||
|
res['month'] = tm.tm_mon
|
||||||
|
res['mday'] = tm.tm_mday
|
||||||
|
res['hour'] = tm.tm_hour - 1
|
||||||
|
if res['hour'] < 0:
|
||||||
|
res['hour'] = 23
|
||||||
|
res['minute'] = tm.tm_min
|
||||||
|
res['second'] = tm.tm_sec
|
||||||
|
# in Python, Monday is 0; in BPC, Sunday is 7 and Monday is 1
|
||||||
|
res['wday'] = tm.tm_wday + 1
|
||||||
|
res['unix'] = int(unixtm) # save the unix time representation
|
||||||
|
return res
|
||||||
|
|
||||||
|
# time fetching part (returns China standard time)
|
||||||
|
def fetchtime(params = {}):
|
||||||
|
delta = 0.0
|
||||||
|
offset = 28800 # BPC time is UTC+8
|
||||||
|
if 'delta' in params:
|
||||||
|
delta = float(params['delta']) / 1000
|
||||||
|
if 'offset' in params: # base offset from UTC in seconds
|
||||||
|
offset = int(params['offset'])
|
||||||
|
if 'server' in params and params['server'] is not None: # NTP server set
|
||||||
|
ntpver = 3
|
||||||
|
if 'version' in params:
|
||||||
|
ntpver = params['version']
|
||||||
|
c = ntplib.NTPClient()
|
||||||
|
resp = c.request(params['server'], version=ntpver)
|
||||||
|
unixtm = resp.tx_time
|
||||||
|
else: # use current system time by default
|
||||||
|
unixtm = time.time()
|
||||||
|
unixtm += offset + delta # account for delta
|
||||||
|
return intreptime(unixtm)
|
||||||
|
|
||||||
|
# timecode generation part
|
||||||
|
# accepts the result of fetchtime function
|
||||||
|
def gentimecode(ts):
|
||||||
|
# convert ts['hour'] to am/pm (BPC variant)
|
||||||
|
pmflag = 0
|
||||||
|
ampmhr = ts['hour'] % 12
|
||||||
|
if ts['hour'] >= 12:
|
||||||
|
pmflag = 1
|
||||||
|
# init the timecode for the whole minute
|
||||||
|
timecode = [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
4,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
4,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||||
|
# populate hour
|
||||||
|
timecode[3] = timecode[23] = timecode[43] = ampmhr >> 2
|
||||||
|
timecode[4] = timecode[24] = timecode[44] = ampmhr & 3
|
||||||
|
# populate minute
|
||||||
|
timecode[5] = timecode[25] = timecode[45] = (ts['minute'] >> 4) & 3
|
||||||
|
timecode[6] = timecode[26] = timecode[46] = (ts['minute'] >> 2) & 3
|
||||||
|
timecode[7] = timecode[27] = timecode[47] = ts['minute'] & 3
|
||||||
|
# populate weekday
|
||||||
|
timecode[8] = timecode[28] = timecode[48] = (ts['wday'] >> 2) & 1
|
||||||
|
timecode[9] = timecode[29] = timecode[49] = ts['wday'] & 3
|
||||||
|
# populate am/pm flag and first part parity
|
||||||
|
timecode[10] = (pmflag << 1) | calc_parity(timecode[1:10])
|
||||||
|
timecode[30] = (pmflag << 1) | calc_parity(timecode[21:30])
|
||||||
|
timecode[50] = (pmflag << 1) | calc_parity(timecode[41:50])
|
||||||
|
# populate day of month
|
||||||
|
timecode[11] = timecode[31] = timecode[51] = ts['mday'] >> 4
|
||||||
|
timecode[12] = timecode[32] = timecode[52] = (ts['mday'] >> 2) & 3
|
||||||
|
timecode[13] = timecode[33] = timecode[53] = ts['mday'] & 3
|
||||||
|
# populate month
|
||||||
|
timecode[14] = timecode[34] = timecode[54] = ts['month'] >> 2
|
||||||
|
timecode[15] = timecode[35] = timecode[55] = ts['month'] & 3
|
||||||
|
# populate year
|
||||||
|
yhbit = ts['year'] >> 6
|
||||||
|
timecode[16] = timecode[36] = timecode[56] = (ts['year'] >> 4) & 3
|
||||||
|
timecode[17] = timecode[37] = timecode[57] = (ts['year'] >> 2) & 3
|
||||||
|
timecode[18] = timecode[38] = timecode[58] = ts['year'] & 3
|
||||||
|
# populate the high year bit and the second part parity
|
||||||
|
timecode[19] = (yhbit << 1) | calc_parity(timecode[11:20])
|
||||||
|
timecode[39] = (yhbit << 1) | calc_parity(timecode[31:40])
|
||||||
|
timecode[59] = (yhbit << 1) | calc_parity(timecode[51:60])
|
||||||
|
return timecode
|
||||||
|
|
||||||
|
# generate an audio data chunk of specified duration
|
||||||
|
def gen_audio(duration, freq=OP_FREQ, sr=48000):
|
||||||
|
smps = int(sr * duration)
|
||||||
|
# create the sine wave array for the whole second
|
||||||
|
rawdata = []
|
||||||
|
for k in range(0, sr):
|
||||||
|
v = math.sin(2 * math.pi * k * freq / sr)
|
||||||
|
if k <= smps: # reduced power mode in the beginning
|
||||||
|
v *= 0.1
|
||||||
|
rawdata.append(int(v * 32767)) # max gain
|
||||||
|
return array.array('h', rawdata).tobytes()
|
||||||
|
|
||||||
|
# global buffers for audio data and current position
|
||||||
|
curstream = b''
|
||||||
|
streampos = 0
|
||||||
|
|
||||||
|
# bitcode transmission callback
|
||||||
|
def bitcode_transmit(in_data, frame_count, time_info, status):
|
||||||
|
global curstream, streampos
|
||||||
|
framelen = frame_count << 1 # 2 bytes per frame as we're using int16
|
||||||
|
framedata = curstream[streampos:streampos+framelen]
|
||||||
|
streampos += framelen
|
||||||
|
return (framedata, pyaudio.paContinue)
|
||||||
|
|
||||||
|
# main logic is here
|
||||||
|
def start_transmission(timeparams):
|
||||||
|
global curstream
|
||||||
|
p = pyaudio.PyAudio()
|
||||||
|
sr = timeparams['sr']
|
||||||
|
mins = timeparams['duration']
|
||||||
|
bpc_bit_chunks = [ # pregenerate the chunks
|
||||||
|
gen_audio(0.1, OP_FREQ, sr), # data bits 00
|
||||||
|
gen_audio(0.2, OP_FREQ, sr), # data bits 01
|
||||||
|
gen_audio(0.3, OP_FREQ, sr), # data bits 10
|
||||||
|
gen_audio(0.4, OP_FREQ, sr), # data bits 11
|
||||||
|
gen_audio(0, OP_FREQ, sr) # transmission start chunk
|
||||||
|
]
|
||||||
|
ts = fetchtime(timeparams) # get the current timestamp
|
||||||
|
print('Time fetched (Unix):', ts['unix'])
|
||||||
|
bitcode = gentimecode(ts)[ts['second']+1:] # slice the rest of current minute
|
||||||
|
nextmin = ts['unix'] - ts['second'] # rewind to start of the minute
|
||||||
|
for i in range(0, mins): # generate bitcode for the next N minutes
|
||||||
|
nextmin += 60 # calc the next minute
|
||||||
|
bitcode += gentimecode(intreptime(nextmin))
|
||||||
|
print("Transmitting... Press Ctrl+C to exit")
|
||||||
|
# wait for the next second to start (roughly, with all the call overhead)
|
||||||
|
time.sleep((1 - datetime.datetime.now().microsecond/1000000)/2)
|
||||||
|
# open a PyAudio stream with callback
|
||||||
|
stream = p.open(format=pyaudio.paInt16, channels=1, frames_per_buffer=16384,
|
||||||
|
rate=sr, output=True, stream_callback=bitcode_transmit)
|
||||||
|
curstream = bpc_bit_chunks[bitcode.pop(0)] # preload the first second
|
||||||
|
while stream.is_active(): # wait for the stream to finish
|
||||||
|
if len(bitcode) > 0: # feed the stream in parallel
|
||||||
|
curstream += bpc_bit_chunks[bitcode.pop(0)]
|
||||||
|
time.sleep(0.75) # feeding the stream should be faster than realtime
|
||||||
|
# close audio
|
||||||
|
stream.stop_stream()
|
||||||
|
stream.close()
|
||||||
|
p.terminate()
|
||||||
|
print("Transmission ended")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
parser = ArgumentParser(description='Beepy: an opensource longwave time synchronizer for BPC-enabled watches and clocks', epilog='(c) Luxferre 2024 --- No rights reserved <https://unlicense.org>')
|
||||||
|
parser.add_argument('-t', '--duration', type=int, default=30, help='Transmission duration (in minutes, default 30)')
|
||||||
|
parser.add_argument('-d', '--delta', type=int, default=0, help='Manual delta correction (in ms, must be determined individually, 0 by default)')
|
||||||
|
parser.add_argument('-o', '--tz-offset', type=float, default=9, help='Timezone offset from UTC to transmit (in hours, default 9 - corresponds to JST)')
|
||||||
|
parser.add_argument('-r', '--sample-rate', type=int, default=48000, help='Transmission sampling rate (in Hz, default 48000)')
|
||||||
|
parser.add_argument('-s', '--ntp-server', type=str, default=None, help='NTP server to sync from (if not specified then will sync from the local system time)')
|
||||||
|
parser.add_argument('-n', '--ntp-version', type=int, default=4, help='NTP protocol version to use (default 4)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
params = { # populate parameters from the command line
|
||||||
|
'server': args.ntp_server, 'version': args.ntp_version,
|
||||||
|
'delta': args.delta, 'offset': int(args.tz_offset * 3600),
|
||||||
|
'sr': args.sample_rate, 'duration': args.duration
|
||||||
|
}
|
||||||
|
start_transmission(params)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pyaudio>=0.2.14
|
||||||
|
ntplib>=0.4.0
|
||||||
Reference in New Issue
Block a user