first upload

This commit is contained in:
Luxferre
2024-12-12 08:46:14 +02:00
commit ffb240d564
3 changed files with 283 additions and 0 deletions
+105
View File
@@ -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.
Executable
+176
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
pyaudio>=0.2.14
ntplib>=0.4.0