[chirp_devel] [FT-90R] initial support for Yaesu FT-90R
# HG changeset patch # User Jens Jensen kd4tjx@yahoo.com # Date 1377497847 18000 # Node ID 7c95e9d39dfc07ea1e3192834d8a339e100347f6 # Parent 86910885e998d559dfdd6ba4c5fb0bf520fdee31 initial support for Yaesu FT-90R, revised #1087
diff -r 86910885e998 -r 7c95e9d39dfc chirp/ft90.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/chirp/ft90.py Mon Aug 26 01:17:27 2013 -0500 @@ -0,0 +1,379 @@ +# Copyright 2011 Dan Smith dsmith@danplanet.com +# Copyright 2013 Jens Jensen kd4tjx@yahoo.com +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +from chirp import chirp_common, bitwise, memmap, directory, errors, util, yaesu_clone +import time, os, traceback + +CHIRP_DEBUG=True +CMD_ACK = chr(0x06) + +@directory.register +class FT90Radio(yaesu_clone.YaesuCloneModeRadio): + VENDOR = "Yaesu" + MODEL = "FT-90" + ID = "\x8E\xF6" + + STEPS = [5, 10, 12.5, 15, 20, 25, 50] + MODES = ["AM", "FM", "Auto"] + TMODES = ["", "Tone", "TSQL", "", "DTCS"] # idx 3 (Bell) not supported yet + + TONES = list(chirp_common.TONES) + for tone in [ 165.5, 171.3, 177.3 ]: + TONES.remove(tone) + POWER_LEVELS = ["Hi", "Mid1", "Mid2", "Low"] + DUPLEX = ["", "-", "+", "split"] + + _memsize = 4063 + # block 03 (200 Bytes long) repeats 18 times; channel memories + _block_lengths = [ 2, 232, 24, 200, 205] + + mem_format = """ + #seekto 0x22; + struct { + u8 dtmf_active; + u8 dtmf1_len; + u8 dtmf2_len; + u8 dtmf3_len; + u8 dtmf4_len; + u8 dtmf5_len; + u8 dtmf6_len; + u8 dtmf7_len; + u8 dtmf8_len; + bbcd dtmf1[8]; + bbcd dtmf2[8]; + bbcd dtmf3[8]; + bbcd dtmf4[8]; + bbcd dtmf5[8]; + bbcd dtmf6[8]; + bbcd dtmf7[8]; + bbcd dtmf8[8]; + char cwid[7]; + u8 unk1; + u8 unk2:2, + beep_dis:1, + unk3:1, + rfsqlvl:4; + u8 cwid_en:1, + unk4:3, + txnarrow:1, + dtmfspeed:1, + pttlock:2; + u8 dtmftxdelay:3, + fancontrol:2, + unk5:3; + u8 dimmer:3, + unk6:1, + lcdcontrast:4; + u8 tot; + u8 unk8:1, + ars:1, + lock:1, + txpwrsave:1, + apo:4; + u8 key_rt; + u8 key_lt; + u8 key_p1; + u8 key_p2; + u8 key_acc; + char demomsg1[32]; + char demomsg2[32]; + + } settings; + + struct mem_struct { + u8 mode:2, + isUhf1:1, + unknown1:2, + step:3; + u8 artsmode:2, + unknown2:1, + isUhf2:1 + power:2, + shift:2; + u8 skip:1, + showname:1, + unknown3:1, + isUhfHi:1, + unknown4:1, + tmode:3; + u32 rxfreq; + u32 txfreqoffset; + u8 UseDefaultName:1, + ars:1, + tone:6; + u8 packetmode:1, + unknown5:1, + dcstone:6; + char name[7]; + }; + + #seekto 0x86; + struct mem_struct vfo_v; + struct mem_struct call_v; + struct mem_struct vfo_u; + struct mem_struct call_u; + + #seekto 0x102; + struct mem_struct memory[180]; + + #seekto 0xf12; + struct mem_struct pms_1L; + struct mem_struct pms_1U; + struct mem_struct pms_2L; + struct mem_struct pms_2U; + """ + + @classmethod + def match_model(cls, filedata, filename): + return len(filedata) == cls._memsize + + def get_features(self): + rf = chirp_common.RadioFeatures() + rf.has_ctone = False + rf.has_bank = False + rf.has_dtcs_polarity = False + rf.has_dtcs = True + rf.valid_modes = self.MODES + rf.valid_tmodes = self.TMODES + rf.valid_duplexes = self.DUPLEX + rf.valid_tuning_steps = self.STEPS + rf.valid_power_levels = self.POWER_LEVELS + rf.valid_name_length = 7 + rf.valid_characters = chirp_common.CHARSET_ASCII + rf.valid_skips = ["", "S"] + rf.memory_bounds = (1, 180) + rf.valid_bands = [(100000000, 230000000), + (300000000, 530000000), (810000000, 999975000)] + + return rf + + def _read(self, blocksize, blocknum): + data = self.pipe.read(blocksize+2) + + # chew echo'd ack + self.pipe.write(CMD_ACK) + time.sleep(0.02) + self.pipe.read(1) # chew echoed ACK from 1-wire serial + + if len(data) == blocksize+2 and data[0] == chr(blocknum): + checksum = yaesu_clone.YaesuChecksum(1, blocksize) + if checksum.get_existing(data) != checksum.get_calculated(data): + raise Exception("Checksum Failed [%02X<>%02X] block %02X, data len: %i" % + (checksum.get_existing(data), + checksum.get_calculated(data), blocknum, len(data) )) + data = data[1:blocksize+1] # Chew blocknum and checksum + + else: + raise Exception("Unable to read blocknum %02X expected blocksize %i got %i." % + (blocknum, blocksize+2, len(data))) + + return data + + def _clone_in(self): + # Be very patient with the radio + self.pipe.setTimeout(4) + start = time.time() + + data = "" + blocknum = 0 + status = chirp_common.Status() + status.msg = "Cloning from radio.\nPut radio into clone mode then\npress SET to send" + self.status_fn(status) + status.max = len(self._block_lengths) + 18 + for blocksize in self._block_lengths: + if blocksize == 200: + # repeated read of 200 block same size (memory area) + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + data += self._read(blocksize, blocknum) + + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + status.msg = "Clone completed." + self.status_fn(status) + + print "Clone completed in %i seconds, blocks read: %i" % (time.time() - start, blocknum) + + return memmap.MemoryMap(data) + + def _clone_out(self): + delay = 0.2 + start = time.time() + + blocknum = 0 + pos = 0 + status = chirp_common.Status() + status.msg = "Cloning to radio.\nPut radio into clone mode and press DISP/SS\n to start receive within 3 secs..." + self.status_fn(status) + # radio likes to have port open + self.pipe.open() + time.sleep(3) + status.max = len(self._block_lengths) + 18 + + + for blocksize in self._block_lengths: + if blocksize == 200: + # repeat channel blocks + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + time.sleep(0.1) + checksum = yaesu_clone.YaesuChecksum(pos, pos+blocksize-1) + blocknumbyte = chr(blocknum) + payloadbytes = self.get_mmap()[pos:pos+blocksize] + checksumbyte = chr(checksum.get_calculated(self.get_mmap())) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "Block %i - will send from %i to %i byte " % \ + (blocknum, pos, pos + blocksize) + print util.hexprint(blocknumbyte) + print util.hexprint(payloadbytes) + print util.hexprint(checksumbyte) + # send wrapped bytes + self.pipe.write(blocknumbyte) + self.pipe.write(payloadbytes) + self.pipe.write(checksumbyte) + tmp = self.pipe.read(blocksize+2) #chew echo + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "bytes echoed: " + print util.hexprint(tmp) + # radio is slow to write/ack: + time.sleep(0.9) + buf = self.pipe.read(1) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "ack recd:" + print util.hexprint(buf) + if buf != CMD_ACK: + raise Exception("Radio did not ack block %i" % blocknum) + pos += blocksize + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + print "Clone completed in %i seconds" % (time.time() - start) + + def sync_in(self): + try: + self._mmap = self._clone_in() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + self.process_mmap() + + def sync_out(self): + try: + self._clone_out() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + + def process_mmap(self): + self._memobj = bitwise.parse(self.mem_format, self._mmap) + + def get_memory(self, number): + _mem = self._memobj.memory[number-1] + + mem = chirp_common.Memory() + mem.number = number + mem.freq = _mem.rxfreq * 10 + mem.offset = _mem.txfreqoffset * 10 + if not _mem.tmode < len(self.TMODES): + _mem.tmode = 0 + mem.tmode = self.TMODES[_mem.tmode] + mem.rtone = self.TONES[_mem.tone] + mem.dtcs = chirp_common.DTCS_CODES[_mem.dcstone] + mem.mode = self.MODES[_mem.mode] + + ''' + # ars mode note yet working... + # ARS mode: + if _mem.ars and _mem.shift == 0: + mem.duplex = self.DUPLEX[4] + else: + mem.duplex = self.DUPLEX[_mem.shift] + ''' + + mem.duplex = self.DUPLEX[_mem.shift] + mem.power = self.POWER_LEVELS[_mem.power] + # radio has a known bug with 5khz step and squelch + if _mem.step == 0: + _mem.step = 2 + mem.tuning_step = self.STEPS[_mem.step] + mem.skip = _mem.skip and "S" or "" + mem.name = _mem.name + return mem + + def get_raw_memory(self, number): + return repr(self._memobj.memory[number-1]) + + def set_memory(self, mem): + + _mem = self._memobj.memory[mem.number - 1] + _mem.skip = mem.skip == "S" + # radio has a known bug with 5khz step and dead squelch + if not mem.tuning_step or mem.tuning_step == self.STEPS[0]: + _mem.step = 2 + else: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.rxfreq = mem.freq / 10 + # vfo will unlock if not in right band? + if mem.freq > 300000000: + # uhf + _mem.isUhf1 = 1 + _mem.isUhf2 = 1 + if mem.freq > 810000000: + # uhf hiband + _mem.isUhfHi = 1 + else: + _mem.isUhfHi = 0 + else: + # vhf + _mem.isUhf1 = 0 + _mem.isUhf2 = 0 + _mem.isUhfHi = 0 + _mem.txfreqoffset = mem.offset / 10 + _mem.tone = self.TONES.index(mem.rtone) + _mem.tmode = self.TMODES.index(mem.tmode) + _mem.mode = self.MODES.index(mem.mode) + ''' + # ars not yet working + # ARS mode: + if mem.duplex == 4: + _mem.shift = 0 + _mem.ars = 1 + else: + _mem.shift = self.DUPLEX.index(mem.duplex) + ''' + _mem.shift = self.DUPLEX.index(mem.duplex) + _mem.dcstone = chirp_common.DTCS_CODES.index(mem.dtcs) + if self.get_features().has_tuning_step: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.shift = self.DUPLEX.index(mem.duplex) + if mem.power: + _mem.power = self.POWER_LEVELS.index(mem.power) + else: + _mem.power = 3 # default to low power + _mem.name = mem.name.ljust(7) + + diff -r 86910885e998 -r 7c95e9d39dfc ft90.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ft90.py Mon Aug 26 01:17:27 2013 -0500 @@ -0,0 +1,379 @@ +# Copyright 2011 Dan Smith dsmith@danplanet.com +# Copyright 2013 Jens Jensen kd4tjx@yahoo.com +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +from chirp import chirp_common, bitwise, memmap, directory, errors, util, yaesu_clone +import time, os, traceback + +CHIRP_DEBUG=True +CMD_ACK = chr(0x06) + +@directory.register +class FT90Radio(yaesu_clone.YaesuCloneModeRadio): + VENDOR = "Yaesu" + MODEL = "FT-90" + ID = "\x8E\xF6" + + STEPS = [5, 10, 12.5, 15, 20, 25, 50] + MODES = ["AM", "FM", "Auto"] + TMODES = ["", "Tone", "TSQL", "", "DTCS"] # idx 3 (Bell) not supported yet + + TONES = list(chirp_common.TONES) + for tone in [ 165.5, 171.3, 177.3 ]: + TONES.remove(tone) + POWER_LEVELS = ["Hi", "Mid1", "Mid2", "Low"] + DUPLEX = ["", "-", "+", "split"] + + _memsize = 4063 + # block 03 (200 Bytes long) repeats 18 times; channel memories + _block_lengths = [ 2, 232, 24, 200, 205] + + mem_format = """ + #seekto 0x22; + struct { + u8 dtmf_active; + u8 dtmf1_len; + u8 dtmf2_len; + u8 dtmf3_len; + u8 dtmf4_len; + u8 dtmf5_len; + u8 dtmf6_len; + u8 dtmf7_len; + u8 dtmf8_len; + bbcd dtmf1[8]; + bbcd dtmf2[8]; + bbcd dtmf3[8]; + bbcd dtmf4[8]; + bbcd dtmf5[8]; + bbcd dtmf6[8]; + bbcd dtmf7[8]; + bbcd dtmf8[8]; + char cwid[7]; + u8 unk1; + u8 unk2:2, + beep_dis:1, + unk3:1, + rfsqlvl:4; + u8 cwid_en:1, + unk4:3, + txnarrow:1, + dtmfspeed:1, + pttlock:2; + u8 dtmftxdelay:3, + fancontrol:2, + unk5:3; + u8 dimmer:3, + unk6:1, + lcdcontrast:4; + u8 tot; + u8 unk8:1, + ars:1, + lock:1, + txpwrsave:1, + apo:4; + u8 key_rt; + u8 key_lt; + u8 key_p1; + u8 key_p2; + u8 key_acc; + char demomsg1[32]; + char demomsg2[32]; + + } settings; + + struct mem_struct { + u8 mode:2, + isUhf1:1, + unknown1:2, + step:3; + u8 artsmode:2, + unknown2:1, + isUhf2:1 + power:2, + shift:2; + u8 skip:1, + showname:1, + unknown3:1, + isUhfHi:1, + unknown4:1, + tmode:3; + u32 rxfreq; + u32 txfreqoffset; + u8 UseDefaultName:1, + ars:1, + tone:6; + u8 packetmode:1, + unknown5:1, + dcstone:6; + char name[7]; + }; + + #seekto 0x86; + struct mem_struct vfo_v; + struct mem_struct call_v; + struct mem_struct vfo_u; + struct mem_struct call_u; + + #seekto 0x102; + struct mem_struct memory[180]; + + #seekto 0xf12; + struct mem_struct pms_1L; + struct mem_struct pms_1U; + struct mem_struct pms_2L; + struct mem_struct pms_2U; + """ + + @classmethod + def match_model(cls, filedata, filename): + return len(filedata) == cls._memsize + + def get_features(self): + rf = chirp_common.RadioFeatures() + rf.has_ctone = False + rf.has_bank = False + rf.has_dtcs_polarity = False + rf.has_dtcs = True + rf.valid_modes = self.MODES + rf.valid_tmodes = self.TMODES + rf.valid_duplexes = self.DUPLEX + rf.valid_tuning_steps = self.STEPS + rf.valid_power_levels = self.POWER_LEVELS + rf.valid_name_length = 7 + rf.valid_characters = chirp_common.CHARSET_ASCII + rf.valid_skips = ["", "S"] + rf.memory_bounds = (1, 180) + rf.valid_bands = [(100000000, 230000000), + (300000000, 530000000), (810000000, 999975000)] + + return rf + + def _read(self, blocksize, blocknum): + data = self.pipe.read(blocksize+2) + + # chew echo'd ack + self.pipe.write(CMD_ACK) + time.sleep(0.02) + self.pipe.read(1) # chew echoed ACK from 1-wire serial + + if len(data) == blocksize+2 and data[0] == chr(blocknum): + checksum = yaesu_clone.YaesuChecksum(1, blocksize) + if checksum.get_existing(data) != checksum.get_calculated(data): + raise Exception("Checksum Failed [%02X<>%02X] block %02X, data len: %i" % + (checksum.get_existing(data), + checksum.get_calculated(data), blocknum, len(data) )) + data = data[1:blocksize+1] # Chew blocknum and checksum + + else: + raise Exception("Unable to read blocknum %02X expected blocksize %i got %i." % + (blocknum, blocksize+2, len(data))) + + return data + + def _clone_in(self): + # Be very patient with the radio + self.pipe.setTimeout(4) + start = time.time() + + data = "" + blocknum = 0 + status = chirp_common.Status() + status.msg = "Cloning from radio.\nPut radio into clone mode then\npress SET to send" + self.status_fn(status) + status.max = len(self._block_lengths) + 18 + for blocksize in self._block_lengths: + if blocksize == 200: + # repeated read of 200 block same size (memory area) + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + data += self._read(blocksize, blocknum) + + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + status.msg = "Clone completed." + self.status_fn(status) + + print "Clone completed in %i seconds, blocks read: %i" % (time.time() - start, blocknum) + + return memmap.MemoryMap(data) + + def _clone_out(self): + delay = 0.2 + start = time.time() + + blocknum = 0 + pos = 0 + status = chirp_common.Status() + status.msg = "Cloning to radio.\nPut radio into clone mode and press DISP/SS\n to start receive within 3 secs..." + self.status_fn(status) + # radio likes to have port open + self.pipe.open() + time.sleep(3) + status.max = len(self._block_lengths) + 18 + + + for blocksize in self._block_lengths: + if blocksize == 200: + # repeat channel blocks + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + time.sleep(0.1) + checksum = yaesu_clone.YaesuChecksum(pos, pos+blocksize-1) + blocknumbyte = chr(blocknum) + payloadbytes = self.get_mmap()[pos:pos+blocksize] + checksumbyte = chr(checksum.get_calculated(self.get_mmap())) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "Block %i - will send from %i to %i byte " % \ + (blocknum, pos, pos + blocksize) + print util.hexprint(blocknumbyte) + print util.hexprint(payloadbytes) + print util.hexprint(checksumbyte) + # send wrapped bytes + self.pipe.write(blocknumbyte) + self.pipe.write(payloadbytes) + self.pipe.write(checksumbyte) + tmp = self.pipe.read(blocksize+2) #chew echo + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "bytes echoed: " + print util.hexprint(tmp) + # radio is slow to write/ack: + time.sleep(0.9) + buf = self.pipe.read(1) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "ack recd:" + print util.hexprint(buf) + if buf != CMD_ACK: + raise Exception("Radio did not ack block %i" % blocknum) + pos += blocksize + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + print "Clone completed in %i seconds" % (time.time() - start) + + def sync_in(self): + try: + self._mmap = self._clone_in() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + self.process_mmap() + + def sync_out(self): + try: + self._clone_out() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + + def process_mmap(self): + self._memobj = bitwise.parse(self.mem_format, self._mmap) + + def get_memory(self, number): + _mem = self._memobj.memory[number-1] + + mem = chirp_common.Memory() + mem.number = number + mem.freq = _mem.rxfreq * 10 + mem.offset = _mem.txfreqoffset * 10 + if not _mem.tmode < len(self.TMODES): + _mem.tmode = 0 + mem.tmode = self.TMODES[_mem.tmode] + mem.rtone = self.TONES[_mem.tone] + mem.dtcs = chirp_common.DTCS_CODES[_mem.dcstone] + mem.mode = self.MODES[_mem.mode] + + ''' + # ars mode note yet working... + # ARS mode: + if _mem.ars and _mem.shift == 0: + mem.duplex = self.DUPLEX[4] + else: + mem.duplex = self.DUPLEX[_mem.shift] + ''' + + mem.duplex = self.DUPLEX[_mem.shift] + mem.power = self.POWER_LEVELS[_mem.power] + # radio has a known bug with 5khz step and squelch + if _mem.step == 0: + _mem.step = 2 + mem.tuning_step = self.STEPS[_mem.step] + mem.skip = _mem.skip and "S" or "" + mem.name = _mem.name + return mem + + def get_raw_memory(self, number): + return repr(self._memobj.memory[number-1]) + + def set_memory(self, mem): + + _mem = self._memobj.memory[mem.number - 1] + _mem.skip = mem.skip == "S" + # radio has a known bug with 5khz step and dead squelch + if not mem.tuning_step or mem.tuning_step == self.STEPS[0]: + _mem.step = 2 + else: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.rxfreq = mem.freq / 10 + # vfo will unlock if not in right band? + if mem.freq > 300000000: + # uhf + _mem.isUhf1 = 1 + _mem.isUhf2 = 1 + if mem.freq > 810000000: + # uhf hiband + _mem.isUhfHi = 1 + else: + _mem.isUhfHi = 0 + else: + # vhf + _mem.isUhf1 = 0 + _mem.isUhf2 = 0 + _mem.isUhfHi = 0 + _mem.txfreqoffset = mem.offset / 10 + _mem.tone = self.TONES.index(mem.rtone) + _mem.tmode = self.TMODES.index(mem.tmode) + _mem.mode = self.MODES.index(mem.mode) + ''' + # ars not yet working + # ARS mode: + if mem.duplex == 4: + _mem.shift = 0 + _mem.ars = 1 + else: + _mem.shift = self.DUPLEX.index(mem.duplex) + ''' + _mem.shift = self.DUPLEX.index(mem.duplex) + _mem.dcstone = chirp_common.DTCS_CODES.index(mem.dtcs) + if self.get_features().has_tuning_step: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.shift = self.DUPLEX.index(mem.duplex) + if mem.power: + _mem.power = self.POWER_LEVELS.index(mem.power) + else: + _mem.power = 3 # default to low power + _mem.name = mem.name.ljust(7) + +
Whoops, got a stray copy of ft90.py in this patch somehow. Dan, can you exclude the ./ft90.py (wrong), but include the chirp/ft90.py (correct)?
________________________________ From: Jens J. kd4tjx@yahoo.com To: "chirp_devel@intrepid.danplanet.com" chirp_devel@intrepid.danplanet.com Sent: Monday, August 26, 2013 1:20 AM Subject: [chirp_devel] [FT-90R] initial support for Yaesu FT-90R
# HG changeset patch # User Jens Jensen kd4tjx@yahoo.com # Date 1377497847 18000 # Node ID 7c95e9d39dfc07ea1e3192834d8a339e100347f6 # Parent 86910885e998d559dfdd6ba4c5fb0bf520fdee31 initial support for Yaesu FT-90R, revised #1087
diff -r 86910885e998 -r 7c95e9d39dfc chirp/ft90.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/chirp/ft90.py Mon Aug 26 01:17:27 2013 -0500 @@ -0,0 +1,379 @@ +# Copyright 2011 Dan Smith dsmith@danplanet.com +# Copyright 2013 Jens Jensen kd4tjx@yahoo.com +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +from chirp import chirp_common, bitwise, memmap, directory, errors, util, yaesu_clone +import time, os, traceback + +CHIRP_DEBUG=True +CMD_ACK = chr(0x06) + +@directory.register +class FT90Radio(yaesu_clone.YaesuCloneModeRadio): + VENDOR = "Yaesu" + MODEL = "FT-90" + ID = "\x8E\xF6" + + STEPS = [5, 10, 12.5, 15, 20, 25, 50] + MODES = ["AM", "FM", "Auto"] + TMODES = ["", "Tone", "TSQL", "", "DTCS"] # idx 3 (Bell) not supported yet + + TONES = list(chirp_common.TONES) + for tone in [ 165.5, 171.3, 177.3 ]: + TONES.remove(tone) + POWER_LEVELS = ["Hi", "Mid1", "Mid2", "Low"] + DUPLEX = ["", "-", "+", "split"] + + _memsize = 4063 + # block 03 (200 Bytes long) repeats 18 times; channel memories + _block_lengths = [ 2, 232, 24, 200, 205] + + mem_format = """ + #seekto 0x22; + struct { + u8 dtmf_active; + u8 dtmf1_len; + u8 dtmf2_len; + u8 dtmf3_len; + u8 dtmf4_len; + u8 dtmf5_len; + u8 dtmf6_len; + u8 dtmf7_len; + u8 dtmf8_len; + bbcd dtmf1[8]; + bbcd dtmf2[8]; + bbcd dtmf3[8]; + bbcd dtmf4[8]; + bbcd dtmf5[8]; + bbcd dtmf6[8]; + bbcd dtmf7[8]; + bbcd dtmf8[8]; + char cwid[7]; + u8 unk1; + u8 unk2:2, + beep_dis:1, + unk3:1, + rfsqlvl:4; + u8 cwid_en:1, + unk4:3, + txnarrow:1, + dtmfspeed:1, + pttlock:2; + u8 dtmftxdelay:3, + fancontrol:2, + unk5:3; + u8 dimmer:3, + unk6:1, + lcdcontrast:4; + u8 tot; + u8 unk8:1, + ars:1, + lock:1, + txpwrsave:1, + apo:4; + u8 key_rt; + u8 key_lt; + u8 key_p1; + u8 key_p2; + u8 key_acc; + char demomsg1[32]; + char demomsg2[32]; + + } settings; + + struct mem_struct { + u8 mode:2, + isUhf1:1, + unknown1:2, + step:3; + u8 artsmode:2, + unknown2:1, + isUhf2:1 + power:2, + shift:2; + u8 skip:1, + showname:1, + unknown3:1, + isUhfHi:1, + unknown4:1, + tmode:3; + u32 rxfreq; + u32 txfreqoffset; + u8 UseDefaultName:1, + ars:1, + tone:6; + u8 packetmode:1, + unknown5:1, + dcstone:6; + char name[7]; + }; + + #seekto 0x86; + struct mem_struct vfo_v; + struct mem_struct call_v; + struct mem_struct vfo_u; + struct mem_struct call_u; + + #seekto 0x102; + struct mem_struct memory[180]; + + #seekto 0xf12; + struct mem_struct pms_1L; + struct mem_struct pms_1U; + struct mem_struct pms_2L; + struct mem_struct pms_2U; + """ + + @classmethod + def match_model(cls, filedata, filename): + return len(filedata) == cls._memsize + + def get_features(self): + rf = chirp_common.RadioFeatures() + rf.has_ctone = False + rf.has_bank = False + rf.has_dtcs_polarity = False + rf.has_dtcs = True + rf.valid_modes = self.MODES + rf.valid_tmodes = self.TMODES + rf.valid_duplexes = self.DUPLEX + rf.valid_tuning_steps = self.STEPS + rf.valid_power_levels = self.POWER_LEVELS + rf.valid_name_length = 7 + rf.valid_characters = chirp_common.CHARSET_ASCII + rf.valid_skips = ["", "S"] + rf.memory_bounds = (1, 180) + rf.valid_bands = [(100000000, 230000000), + (300000000, 530000000), (810000000, 999975000)] + + return rf + + def _read(self, blocksize, blocknum): + data = self.pipe.read(blocksize+2) + + # chew echo'd ack + self.pipe.write(CMD_ACK) + time.sleep(0.02) + self.pipe.read(1) # chew echoed ACK from 1-wire serial + + if len(data) == blocksize+2 and data[0] == chr(blocknum): + checksum = yaesu_clone.YaesuChecksum(1, blocksize) + if checksum.get_existing(data) != checksum.get_calculated(data): + raise Exception("Checksum Failed [%02X<>%02X] block %02X, data len: %i" % + (checksum.get_existing(data), + checksum.get_calculated(data), blocknum, len(data) )) + data = data[1:blocksize+1] # Chew blocknum and checksum + + else: + raise Exception("Unable to read blocknum %02X expected blocksize %i got %i." % + (blocknum, blocksize+2, len(data))) + + return data + + def _clone_in(self): + # Be very patient with the radio + self.pipe.setTimeout(4) + start = time.time() + + data = "" + blocknum = 0 + status = chirp_common.Status() + status.msg = "Cloning from radio.\nPut radio into clone mode then\npress SET to send" + self.status_fn(status) + status.max = len(self._block_lengths) + 18 + for blocksize in self._block_lengths: + if blocksize == 200: + # repeated read of 200 block same size (memory area) + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + data += self._read(blocksize, blocknum) + + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + status.msg = "Clone completed." + self.status_fn(status) + + print "Clone completed in %i seconds, blocks read: %i" % (time.time() - start, blocknum) + + return memmap.MemoryMap(data) + + def _clone_out(self): + delay = 0.2 + start = time.time() + + blocknum = 0 + pos = 0 + status = chirp_common.Status() + status.msg = "Cloning to radio.\nPut radio into clone mode and press DISP/SS\n to start receive within 3 secs..." + self.status_fn(status) + # radio likes to have port open + self.pipe.open() + time.sleep(3) + status.max = len(self._block_lengths) + 18 + + + for blocksize in self._block_lengths: + if blocksize == 200: + # repeat channel blocks + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + time.sleep(0.1) + checksum = yaesu_clone.YaesuChecksum(pos, pos+blocksize-1) + blocknumbyte = chr(blocknum) + payloadbytes = self.get_mmap()[pos:pos+blocksize] + checksumbyte = chr(checksum.get_calculated(self.get_mmap())) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "Block %i - will send from %i to %i byte " % \ + (blocknum, pos, pos + blocksize) + print util.hexprint(blocknumbyte) + print util.hexprint(payloadbytes) + print util.hexprint(checksumbyte) + # send wrapped bytes + self.pipe.write(blocknumbyte) + self.pipe.write(payloadbytes) + self.pipe.write(checksumbyte) + tmp = self.pipe.read(blocksize+2) #chew echo + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "bytes echoed: " + print util.hexprint(tmp) + # radio is slow to write/ack: + time.sleep(0.9) + buf = self.pipe.read(1) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "ack recd:" + print util.hexprint(buf) + if buf != CMD_ACK: + raise Exception("Radio did not ack block %i" % blocknum) + pos += blocksize + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + print "Clone completed in %i seconds" % (time.time() - start) + + def sync_in(self): + try: + self._mmap = self._clone_in() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + self.process_mmap() + + def sync_out(self): + try: + self._clone_out() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + + def process_mmap(self): + self._memobj = bitwise.parse(self.mem_format, self._mmap) + + def get_memory(self, number): + _mem = self._memobj.memory[number-1] + + mem = chirp_common.Memory() + mem.number = number + mem.freq = _mem.rxfreq * 10 + mem.offset = _mem.txfreqoffset * 10 + if not _mem.tmode < len(self.TMODES): + _mem.tmode = 0 + mem.tmode = self.TMODES[_mem.tmode] + mem.rtone = self.TONES[_mem.tone] + mem.dtcs = chirp_common.DTCS_CODES[_mem.dcstone] + mem.mode = self.MODES[_mem.mode] + + ''' + # ars mode note yet working... + # ARS mode: + if _mem.ars and _mem.shift == 0: + mem.duplex = self.DUPLEX[4] + else: + mem.duplex = self.DUPLEX[_mem.shift] + ''' + + mem.duplex = self.DUPLEX[_mem.shift] + mem.power = self.POWER_LEVELS[_mem.power] + # radio has a known bug with 5khz step and squelch + if _mem.step == 0: + _mem.step = 2 + mem.tuning_step = self.STEPS[_mem.step] + mem.skip = _mem.skip and "S" or "" + mem.name = _mem.name + return mem + + def get_raw_memory(self, number): + return repr(self._memobj.memory[number-1]) + + def set_memory(self, mem): + + _mem = self._memobj.memory[mem.number - 1] + _mem.skip = mem.skip == "S" + # radio has a known bug with 5khz step and dead squelch + if not mem.tuning_step or mem.tuning_step == self.STEPS[0]: + _mem.step = 2 + else: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.rxfreq = mem.freq / 10 + # vfo will unlock if not in right band? + if mem.freq > 300000000: + # uhf + _mem.isUhf1 = 1 + _mem.isUhf2 = 1 + if mem.freq > 810000000: + # uhf hiband + _mem.isUhfHi = 1 + else: + _mem.isUhfHi = 0 + else: + # vhf + _mem.isUhf1 = 0 + _mem.isUhf2 = 0 + _mem.isUhfHi = 0 + _mem.txfreqoffset = mem.offset / 10 + _mem.tone = self.TONES.index(mem.rtone) + _mem.tmode = self.TMODES.index(mem.tmode) + _mem.mode = self.MODES.index(mem.mode) + ''' + # ars not yet working + # ARS mode: + if mem.duplex == 4: + _mem.shift = 0 + _mem.ars = 1 + else: + _mem.shift = self.DUPLEX.index(mem.duplex) + ''' + _mem.shift = self.DUPLEX.index(mem.duplex) + _mem.dcstone = chirp_common.DTCS_CODES.index(mem.dtcs) + if self.get_features().has_tuning_step: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.shift = self.DUPLEX.index(mem.duplex) + if mem.power: + _mem.power = self.POWER_LEVELS.index(mem.power) + else: + _mem.power = 3 # default to low power + _mem.name = mem.name.ljust(7) + + diff -r 86910885e998 -r 7c95e9d39dfc ft90.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ft90.py Mon Aug 26 01:17:27 2013 -0500 @@ -0,0 +1,379 @@ +# Copyright 2011 Dan Smith dsmith@danplanet.com +# Copyright 2013 Jens Jensen kd4tjx@yahoo.com +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +from chirp import chirp_common, bitwise, memmap, directory, errors, util, yaesu_clone +import time, os, traceback + +CHIRP_DEBUG=True +CMD_ACK = chr(0x06) + +@directory.register +class FT90Radio(yaesu_clone.YaesuCloneModeRadio): + VENDOR = "Yaesu" + MODEL = "FT-90" + ID = "\x8E\xF6" + + STEPS = [5, 10, 12.5, 15, 20, 25, 50] + MODES = ["AM", "FM", "Auto"] + TMODES = ["", "Tone", "TSQL", "", "DTCS"] # idx 3 (Bell) not supported yet + + TONES = list(chirp_common.TONES) + for tone in [ 165.5, 171.3, 177.3 ]: + TONES.remove(tone) + POWER_LEVELS = ["Hi", "Mid1", "Mid2", "Low"] + DUPLEX = ["", "-", "+", "split"] + + _memsize = 4063 + # block 03 (200 Bytes long) repeats 18 times; channel memories + _block_lengths = [ 2, 232, 24, 200, 205] + + mem_format = """ + #seekto 0x22; + struct { + u8 dtmf_active; + u8 dtmf1_len; + u8 dtmf2_len; + u8 dtmf3_len; + u8 dtmf4_len; + u8 dtmf5_len; + u8 dtmf6_len; + u8 dtmf7_len; + u8 dtmf8_len; + bbcd dtmf1[8]; + bbcd dtmf2[8]; + bbcd dtmf3[8]; + bbcd dtmf4[8]; + bbcd dtmf5[8]; + bbcd dtmf6[8]; + bbcd dtmf7[8]; + bbcd dtmf8[8]; + char cwid[7]; + u8 unk1; + u8 unk2:2, + beep_dis:1, + unk3:1, + rfsqlvl:4; + u8 cwid_en:1, + unk4:3, + txnarrow:1, + dtmfspeed:1, + pttlock:2; + u8 dtmftxdelay:3, + fancontrol:2, + unk5:3; + u8 dimmer:3, + unk6:1, + lcdcontrast:4; + u8 tot; + u8 unk8:1, + ars:1, + lock:1, + txpwrsave:1, + apo:4; + u8 key_rt; + u8 key_lt; + u8 key_p1; + u8 key_p2; + u8 key_acc; + char demomsg1[32]; + char demomsg2[32]; + + } settings; + + struct mem_struct { + u8 mode:2, + isUhf1:1, + unknown1:2, + step:3; + u8 artsmode:2, + unknown2:1, + isUhf2:1 + power:2, + shift:2; + u8 skip:1, + showname:1, + unknown3:1, + isUhfHi:1, + unknown4:1, + tmode:3; + u32 rxfreq; + u32 txfreqoffset; + u8 UseDefaultName:1, + ars:1, + tone:6; + u8 packetmode:1, + unknown5:1, + dcstone:6; + char name[7]; + }; + + #seekto 0x86; + struct mem_struct vfo_v; + struct mem_struct call_v; + struct mem_struct vfo_u; + struct mem_struct call_u; + + #seekto 0x102; + struct mem_struct memory[180]; + + #seekto 0xf12; + struct mem_struct pms_1L; + struct mem_struct pms_1U; + struct mem_struct pms_2L; + struct mem_struct pms_2U; + """ + + @classmethod + def match_model(cls, filedata, filename): + return len(filedata) == cls._memsize + + def get_features(self): + rf = chirp_common.RadioFeatures() + rf.has_ctone = False + rf.has_bank = False + rf.has_dtcs_polarity = False + rf.has_dtcs = True + rf.valid_modes = self.MODES + rf.valid_tmodes = self.TMODES + rf.valid_duplexes = self.DUPLEX + rf.valid_tuning_steps = self.STEPS + rf.valid_power_levels = self.POWER_LEVELS + rf.valid_name_length = 7 + rf.valid_characters = chirp_common.CHARSET_ASCII + rf.valid_skips = ["", "S"] + rf.memory_bounds = (1, 180) + rf.valid_bands = [(100000000, 230000000), + (300000000, 530000000), (810000000, 999975000)] + + return rf + + def _read(self, blocksize, blocknum): + data = self.pipe.read(blocksize+2) + + # chew echo'd ack + self.pipe.write(CMD_ACK) + time.sleep(0.02) + self.pipe.read(1) # chew echoed ACK from 1-wire serial + + if len(data) == blocksize+2 and data[0] == chr(blocknum): + checksum = yaesu_clone.YaesuChecksum(1, blocksize) + if checksum.get_existing(data) != checksum.get_calculated(data): + raise Exception("Checksum Failed [%02X<>%02X] block %02X, data len: %i" % + (checksum.get_existing(data), + checksum.get_calculated(data), blocknum, len(data) )) + data = data[1:blocksize+1] # Chew blocknum and checksum + + else: + raise Exception("Unable to read blocknum %02X expected blocksize %i got %i." % + (blocknum, blocksize+2, len(data))) + + return data + + def _clone_in(self): + # Be very patient with the radio + self.pipe.setTimeout(4) + start = time.time() + + data = "" + blocknum = 0 + status = chirp_common.Status() + status.msg = "Cloning from radio.\nPut radio into clone mode then\npress SET to send" + self.status_fn(status) + status.max = len(self._block_lengths) + 18 + for blocksize in self._block_lengths: + if blocksize == 200: + # repeated read of 200 block same size (memory area) + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + data += self._read(blocksize, blocknum) + + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + status.msg = "Clone completed." + self.status_fn(status) + + print "Clone completed in %i seconds, blocks read: %i" % (time.time() - start, blocknum) + + return memmap.MemoryMap(data) + + def _clone_out(self): + delay = 0.2 + start = time.time() + + blocknum = 0 + pos = 0 + status = chirp_common.Status() + status.msg = "Cloning to radio.\nPut radio into clone mode and press DISP/SS\n to start receive within 3 secs..." + self.status_fn(status) + # radio likes to have port open + self.pipe.open() + time.sleep(3) + status.max = len(self._block_lengths) + 18 + + + for blocksize in self._block_lengths: + if blocksize == 200: + # repeat channel blocks + repeat = 18 + else: + repeat = 1 + for _i in range(0, repeat): + time.sleep(0.1) + checksum = yaesu_clone.YaesuChecksum(pos, pos+blocksize-1) + blocknumbyte = chr(blocknum) + payloadbytes = self.get_mmap()[pos:pos+blocksize] + checksumbyte = chr(checksum.get_calculated(self.get_mmap())) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "Block %i - will send from %i to %i byte " % \ + (blocknum, pos, pos + blocksize) + print util.hexprint(blocknumbyte) + print util.hexprint(payloadbytes) + print util.hexprint(checksumbyte) + # send wrapped bytes + self.pipe.write(blocknumbyte) + self.pipe.write(payloadbytes) + self.pipe.write(checksumbyte) + tmp = self.pipe.read(blocksize+2) #chew echo + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "bytes echoed: " + print util.hexprint(tmp) + # radio is slow to write/ack: + time.sleep(0.9) + buf = self.pipe.read(1) + if os.getenv("CHIRP_DEBUG") or CHIRP_DEBUG: + print "ack recd:" + print util.hexprint(buf) + if buf != CMD_ACK: + raise Exception("Radio did not ack block %i" % blocknum) + pos += blocksize + blocknum += 1 + status.cur = blocknum + self.status_fn(status) + + print "Clone completed in %i seconds" % (time.time() - start) + + def sync_in(self): + try: + self._mmap = self._clone_in() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + self.process_mmap() + + def sync_out(self): + try: + self._clone_out() + except errors.RadioError: + raise + except Exception, e: + trace = traceback.format_exc() + raise errors.RadioError("Failed to communicate with radio: %s" % trace) + + def process_mmap(self): + self._memobj = bitwise.parse(self.mem_format, self._mmap) + + def get_memory(self, number): + _mem = self._memobj.memory[number-1] + + mem = chirp_common.Memory() + mem.number = number + mem.freq = _mem.rxfreq * 10 + mem.offset = _mem.txfreqoffset * 10 + if not _mem.tmode < len(self.TMODES): + _mem.tmode = 0 + mem.tmode = self.TMODES[_mem.tmode] + mem.rtone = self.TONES[_mem.tone] + mem.dtcs = chirp_common.DTCS_CODES[_mem.dcstone] + mem.mode = self.MODES[_mem.mode] + + ''' + # ars mode note yet working... + # ARS mode: + if _mem.ars and _mem.shift == 0: + mem.duplex = self.DUPLEX[4] + else: + mem.duplex = self.DUPLEX[_mem.shift] + ''' + + mem.duplex = self.DUPLEX[_mem.shift] + mem.power = self.POWER_LEVELS[_mem.power] + # radio has a known bug with 5khz step and squelch + if _mem.step == 0: + _mem.step = 2 + mem.tuning_step = self.STEPS[_mem.step] + mem.skip = _mem.skip and "S" or "" + mem.name = _mem.name + return mem + + def get_raw_memory(self, number): + return repr(self._memobj.memory[number-1]) + + def set_memory(self, mem): + + _mem = self._memobj.memory[mem.number - 1] + _mem.skip = mem.skip == "S" + # radio has a known bug with 5khz step and dead squelch + if not mem.tuning_step or mem.tuning_step == self.STEPS[0]: + _mem.step = 2 + else: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.rxfreq = mem.freq / 10 + # vfo will unlock if not in right band? + if mem.freq > 300000000: + # uhf + _mem.isUhf1 = 1 + _mem.isUhf2 = 1 + if mem.freq > 810000000: + # uhf hiband + _mem.isUhfHi = 1 + else: + _mem.isUhfHi = 0 + else: + # vhf + _mem.isUhf1 = 0 + _mem.isUhf2 = 0 + _mem.isUhfHi = 0 + _mem.txfreqoffset = mem.offset / 10 + _mem.tone = self.TONES.index(mem.rtone) + _mem.tmode = self.TMODES.index(mem.tmode) + _mem.mode = self.MODES.index(mem.mode) + ''' + # ars not yet working + # ARS mode: + if mem.duplex == 4: + _mem.shift = 0 + _mem.ars = 1 + else: + _mem.shift = self.DUPLEX.index(mem.duplex) + ''' + _mem.shift = self.DUPLEX.index(mem.duplex) + _mem.dcstone = chirp_common.DTCS_CODES.index(mem.dtcs) + if self.get_features().has_tuning_step: + _mem.step = self.STEPS.index(mem.tuning_step) + _mem.shift = self.DUPLEX.index(mem.duplex) + if mem.power: + _mem.power = self.POWER_LEVELS.index(mem.power) + else: + _mem.power = 3 # default to low power + _mem.name = mem.name.ljust(7) + +
_______________________________________________ chirp_devel mailing list chirp_devel@intrepid.danplanet.com http://intrepid.danplanet.com/mailman/listinfo/chirp_devel Developer docs: http://chirp.danplanet.com/projects/chirp/wiki/Developers
Dan, can you exclude the ./ft90.py (wrong), but include the chirp/ft90.py (correct)?
It gets applied as a whole when imported into the repository (in order to keep the authorship and parent relationships) (I do an 'hg import' command, for reference).
Is it an mq patch? If so, try:
hg qref -X ./ft90.py
if not, try:
hg rm ft90.py hg commit --amend
participants (2)
-
Dan Smith
-
Jens J.