#!/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 ') 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)