[chirp_devel] [PATCH] Add metadata blob trailer transparently to image files
# HG changeset patch # User Dan Smith dsmith@danplanet.com # Date 1533593320 25200 # Mon Aug 06 15:08:40 2018 -0700 # Node ID e8d26a9a98c56c6a8bed7967b6b2e089f9ac0b33 # Parent 4f6978a84b3e461b13c6e0f996d3bec058431b70 Add metadata blob trailer transparently to image files
This makes FileBackedRadio transparently save image files with an extra blob of metadata tacked onto the end of the file. The metadata region begins with a magic string of "chirp-img0001-" followed by a base64-encoded json blob. I figure we can call all images created up until now "version zero" format, since they really aren't formatted at all. Any major format changes after this can increment the counter in the magic (although it's unlikely we would need to).
The metadata currently saved includes the radio model details and the class used at the time the file was created. All existing images will still load, but when they are saved, will be augmented with metadata.
One major feature this brings is the ability to make radio aliases (and other weak subclasses) be "sticky" across save/load cycles. Right now if you download with an alias (such as the Arcshell AR-6 alias of the BF-888), the file will be detected and reported as a BF-888 from that point forward. With metadata in the image, we can properly select the alias on load to provide better UX.
This includes some low-effort tweaking of the directory and clone code to select the right radio class based on metadata if present, and to properly return a radio class for the alias, if one matches. We might want to re-structure the alias class arrangement after this to make them properly-registered and fully-functional subclasses so that the hacky DynamicRadioAlias subclassing done here isn't necessary.
Related to will-be-a-real-issue #0
diff -r 4f6978a84b3e -r e8d26a9a98c5 chirp/chirp_common.py --- a/chirp/chirp_common.py Sun Jul 01 17:52:10 2018 +0200 +++ b/chirp/chirp_common.py Mon Aug 06 15:08:40 2018 -0700 @@ -13,9 +13,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see http://www.gnu.org/licenses/.
+import base64 +import json +import logging import math from chirp import errors, memmap
+LOG = logging.getLogger(__name__) + SEPCHAR = ","
# 50 Tones @@ -1104,6 +1109,7 @@ class FileBackedRadio(Radio): """A file-backed radio stores its data in a file""" FILE_EXTENSION = "img" + MAGIC = 'chirp-img0001'
def __init__(self, *args, **kwargs): Radio.__init__(self, *args, **kwargs) @@ -1121,10 +1127,45 @@ """Process a newly-loaded or downloaded memory map""" pass
+ @classmethod + def _strip_metadata(cls, raw_data): + try: + idx = raw_data.index(cls.MAGIC) + except ValueError: + LOG.debug('Image data has no metadata blob') + return raw_data, {} + + # Find the beginning of the base64 blob + raw_metadata = raw_data[idx + len(cls.MAGIC) + 1:] + metadata = {} + try: + metadata = json.loads(base64.b64decode(raw_metadata)) + except ValueError as e: + LOG.error('Failed to parse decoded metadata blob: %s' % e) + except TypeError as e: + LOG.error('Failed to decode metadata blob: %s' % e) + + if metadata: + LOG.debug('Loaded metadata: %s' % metadata) + + return raw_data[:idx], metadata + + @classmethod + def _make_metadata(cls): + return base64.b64encode(json.dumps( + {'rclass': cls.__name__, + 'vendor': cls.VENDOR, + 'model': cls.MODEL, + 'variant': cls.VARIANT, + })) + def load_mmap(self, filename): """Load the radio's memory map from @filename""" mapfile = file(filename, "rb") - self._mmap = memmap.MemoryMap(mapfile.read()) + data = mapfile.read() + if self.MAGIC in data: + data, metadata = self._strip_metadata(data) + self._mmap = memmap.MemoryMap(data) mapfile.close() self.process_mmap()
@@ -1136,6 +1177,8 @@ try: mapfile = file(filename, "wb") mapfile.write(self._mmap.get_packed()) + mapfile.write(self.MAGIC + '-') + mapfile.write(self._make_metadata()) mapfile.close() except IOError: raise Exception("File Access Error") diff -r 4f6978a84b3e -r e8d26a9a98c5 chirp/directory.py --- a/chirp/directory.py Sun Jul 01 17:52:10 2018 +0200 +++ b/chirp/directory.py Mon Aug 06 15:08:40 2018 -0700 @@ -137,9 +137,26 @@ else: filedata = ""
+ data, metadata = chirp_common.FileBackedRadio._strip_metadata(filedata) + for rclass in DRV_TO_RADIO.values(): if not issubclass(rclass, chirp_common.FileBackedRadio): continue - if rclass.match_model(filedata, image_file): + + # If no metadata, we do the old thing + if not metadata and rclass.match_model(filedata, image_file): return rclass(image_file) + + # If metadata, then it has to match one of the aliases or the parent + for alias in rclass.ALIASES + [rclass]: + if (alias.VENDOR == metadata.get('vendor') and + alias.MODEL == metadata.get('model')): + + class DynamicRadioAlias(rclass): + VENDOR = metadata.get('vendor') + MODEL = metadata.get('model') + VARIANT = metadata.get('variant') + + return DynamicRadioAlias(image_file) + raise errors.ImageDetectFailed("Unknown file format") diff -r 4f6978a84b3e -r e8d26a9a98c5 chirp/ui/clone.py --- a/chirp/ui/clone.py Sun Jul 01 17:52:10 2018 +0200 +++ b/chirp/ui/clone.py Mon Aug 06 15:08:40 2018 -0700 @@ -191,9 +191,16 @@ for alias in rclass.ALIASES: if alias.MODEL == model: alias_match = rclass + alias_class = alias break if alias_match: - cs.radio_class = rclass + + class DynamicRadioAlias(rclass): + VENDOR = alias.VENDOR + MODEL = alias.MODEL + VARIANT = alias.VARIANT + + cs.radio_class = DynamicRadioAlias LOG.debug( 'Chose %s alias for %s because model %s selected' % ( alias_match, cs.radio_class, model)) diff -r 4f6978a84b3e -r e8d26a9a98c5 tests/unit/test_chirp_common.py --- a/tests/unit/test_chirp_common.py Sun Jul 01 17:52:10 2018 +0200 +++ b/tests/unit/test_chirp_common.py Mon Aug 06 15:08:40 2018 -0700 @@ -1,3 +1,10 @@ +import base64 +import json +import os +import tempfile + +import mock + from tests.unit import base from chirp import chirp_common from chirp import errors @@ -253,3 +260,84 @@ def test_fix_rounded_step_750(self): self.assertEqual(146118750, chirp_common.fix_rounded_step(146118000)) + + +class TestImageMetadata(base.BaseTest): + def test_make_metadata(self): + class TestRadio(chirp_common.FileBackedRadio): + VENDOR = 'Dan' + MODEL = 'Foomaster 9000' + VARIANT = 'R' + + raw_metadata = TestRadio._make_metadata() + metadata = json.loads(base64.b64decode(raw_metadata)) + expected = { + 'vendor': 'Dan', + 'model': 'Foomaster 9000', + 'variant': 'R', + 'rclass': 'TestRadio', + } + self.assertEqual(expected, metadata) + + def test_strip_metadata(self): + class TestRadio(chirp_common.FileBackedRadio): + VENDOR = 'Dan' + MODEL = 'Foomaster 9000' + VARIANT = 'R' + + raw_metadata = TestRadio._make_metadata() + raw_data = ('foooooooooooooooooooooo' + TestRadio.MAGIC + '-' + + TestRadio._make_metadata()) + data, metadata = chirp_common.FileBackedRadio._strip_metadata(raw_data) + self.assertEqual('foooooooooooooooooooooo', data) + expected = { + 'vendor': 'Dan', + 'model': 'Foomaster 9000', + 'variant': 'R', + 'rclass': 'TestRadio', + } + self.assertEqual(expected, metadata) + + def test_load_mmap_no_metadata(self): + f = tempfile.NamedTemporaryFile() + f.write('thisisrawdata') + f.flush() + + with mock.patch('chirp.memmap.MemoryMap') as mock_mmap: + chirp_common.FileBackedRadio(None).load_mmap(f.name) + mock_mmap.assert_called_once_with('thisisrawdata') + + def test_load_mmap_bad_metadata(self): + f = tempfile.NamedTemporaryFile() + f.write('thisisrawdata') + f.write(chirp_common.FileBackedRadio.MAGIC + '-' + 'bad') + f.flush() + + with mock.patch('chirp.memmap.MemoryMap') as mock_mmap: + chirp_common.FileBackedRadio(None).load_mmap(f.name) + mock_mmap.assert_called_once_with('thisisrawdata') + + def test_save_mmap_includes_metadata(self): + class TestRadio(chirp_common.FileBackedRadio): + VENDOR = 'Dan' + MODEL = 'Foomaster 9000' + VARIANT = 'R' + + with tempfile.NamedTemporaryFile() as f: + fn = f.name + r = TestRadio(None) + r._mmap = mock.Mock() + r._mmap.get_packed.return_value = 'thisisrawdata' + r.save_mmap(fn) + with file(fn) as f: + filedata = f.read() + os.remove(fn) + data, metadata = chirp_common.FileBackedRadio._strip_metadata(filedata) + self.assertEqual('thisisrawdata', data) + expected = { + 'vendor': 'Dan', + 'model': 'Foomaster 9000', + 'variant': 'R', + 'rclass': 'TestRadio', + } + self.assertEqual(expected, metadata) diff -r 4f6978a84b3e -r e8d26a9a98c5 tests/unit/test_directory.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/unit/test_directory.py Mon Aug 06 15:08:40 2018 -0700 @@ -0,0 +1,69 @@ +import base64 +import json +import tempfile + +from tests.unit import base +from chirp import chirp_common +from chirp import directory + + +class TestDirectory(base.BaseTest): + def setUp(self): + super(TestDirectory, self).setUp() + + directory.enable_reregistrations() + + class FakeAlias(chirp_common.Alias): + VENDOR = 'Taylor' + MODEL = 'Barmaster 2000' + VARIANT = 'A' + + @directory.register + class FakeRadio(chirp_common.FileBackedRadio): + VENDOR = 'Dan' + MODEL = 'Foomaster 9000' + VARIANT = 'R' + ALIASES = [FakeAlias] + + @classmethod + def match_model(cls, file_data, image_file): + return file_data == 'thisisrawdata' + + self.test_class = FakeRadio + + def _test_detect_finds_our_class(self, tempfn): + radio = directory.get_radio_by_image(tempfn) + self.assertTrue(isinstance(radio, self.test_class)) + return radio + + def test_detect_with_no_metadata(self): + with tempfile.NamedTemporaryFile() as f: + f.write('thisisrawdata') + f.flush() + self._test_detect_finds_our_class(f.name) + + def test_detect_with_metadata_base_class(self): + with tempfile.NamedTemporaryFile() as f: + f.write('thisisrawdata') + f.write(self.test_class.MAGIC + '-') + f.write(self.test_class._make_metadata()) + f.flush() + self._test_detect_finds_our_class(f.name) + + def test_detect_with_metadata_alias_class(self): + with tempfile.NamedTemporaryFile() as f: + f.write('thisisrawdata') + f.write(self.test_class.MAGIC + '-') + FakeAlias = self.test_class.ALIASES[0] + fake_metadata = base64.b64encode(json.dumps( + {'vendor': FakeAlias.VENDOR, + 'model': FakeAlias.MODEL, + 'variant': FakeAlias.VARIANT, + })) + f.write(fake_metadata) + f.flush() + radio = self._test_detect_finds_our_class(f.name) + self.assertEqual('Taylor', radio.VENDOR) + self.assertEqual('Barmaster 2000', radio.MODEL) + self.assertEqual('A', radio.VARIANT) +
# HG changeset patch # User Dan Smith dsmith@danplanet.com # Date 1533593320 25200 # Mon Aug 06 15:08:40 2018 -0700 # Node ID e8d26a9a98c56c6a8bed7967b6b2e089f9ac0b33 # Parent 4f6978a84b3e461b13c6e0f996d3bec058431b70 Add metadata blob trailer transparently to image files
Looking for feedback and/or testing on this if anyone is interested.
It should be fairly straightforward, but because it causes chirp to start generating images in a format that older versions won't open, I think it's worth extra eyes to make sure it's sound. If anyone thinks we need to go to extra effort to allow saving in the old format specifically, please speak up.
Thanks!
--Dan
On Mon, Aug 6, 2018 at 3:14 PM Dan Smith via chirp_devel chirp_devel@intrepid.danplanet.com wrote:
# HG changeset patch # User Dan Smith dsmith@danplanet.com # Date 1533593320 25200 # Mon Aug 06 15:08:40 2018 -0700 # Node ID e8d26a9a98c56c6a8bed7967b6b2e089f9ac0b33 # Parent 4f6978a84b3e461b13c6e0f996d3bec058431b70 Add metadata blob trailer transparently to image files
Looking for feedback and/or testing on this if anyone is interested.
LGTM! I know you have been kicking around this idea for a long time and it's nice to see it come to fruition.
Thing is, I've always referred to my favorite repeater as "chirp-img0001-", and that's how I label it in my radios. I hope your new feature can support my unique use case.
It should be fairly straightforward, but because it causes chirp to start generating images in a format that older versions won't open, I think it's worth extra eyes to make sure it's sound. If anyone thinks we need to go to extra effort to allow saving in the old format specifically, please speak up.
Changing the file extension seems like a pretty low cost way to support both formats and allow mere mortals to identify them in a directory. I can't think of a use case though... Pros? Cons?
Tom
Thing is, I've always referred to my favorite repeater as "chirp-img0001-", and that's how I label it in my radios. I hope your new feature can support my unique use case.
Yeah, I didn't give a lot of thought to the magic, perhaps I should throw something non-printable in there and be a little more strict about where it is in the file.
Changing the file extension seems like a pretty low cost way to support both formats and allow mere mortals to identify them in a directory. I can't think of a use case though... Pros? Cons?
The only pro would be sharing the file with older versions of chirp, either because they have something on an old machine and can't upgrade or are sharing with someone that won't. However, we've never dropped support for a major platform so I don't think there is any reason for someone to not upgrade.
I remember some consternation over the formats last time I tried to introduce one, which is why I'm loathe to make a big deal out of it. I think my preference would be to just note that it's happening and if there is real evidence of it being a problem for people then work backwards to support the old way.
--Dan
On Tue, Aug 7, 2018 at 6:28 AM Dan Smith via chirp_devel chirp_devel@intrepid.danplanet.com wrote:
Thing is, I've always referred to my favorite repeater as "chirp-img0001-", and that's how I label it in my radios. I hope your new feature can support my unique use case.
Yeah, I didn't give a lot of thought to the magic, perhaps I should throw something non-printable in there and be a little more strict about where it is in the file.
🐖📻📡 (joke aside, since this is printable it's not the best option)
Changing the file extension seems like a pretty low cost way to support both formats and allow mere mortals to identify them in a directory. I can't think of a use case though... Pros? Cons?
The only pro would be sharing the file with older versions of chirp, either because they have something on an old machine and can't upgrade or are sharing with someone that won't.
Had to sleep on it, but I remembered a use case. We can currently save-as .vx5, allowing .img edited by Chirp to be opened with VX5 Commander. VX5 Commander also uses the simple flash-dump image format, but with a .vx5 extension. Will this still be possible? Looks like the driver could override save_mmap() to restore the original functionality.
The Motorola Waris Codeplug Tool works with simple flash images as well.
I've started work on a number of Chirp drivers that don't implement upload/download, but just read a binary file from disk (downloaded with first-party software), modify it, and expect the first-party software to be able to open and upload it back to the radio.
Tom
🐖📻📡 (joke aside, since this is printable it's not the best option)
Yup, changed.
Had to sleep on it, but I remembered a use case. We can currently save-as .vx5, allowing .img edited by Chirp to be opened with VX5 Commander. VX5 Commander also uses the simple flash-dump image format, but with a .vx5 extension. Will this still be possible? Looks like the driver could override save_mmap() to restore the original functionality.
The only driver I know of that overrides it currently is the csv one, which works fine.
I tried the .vx5 save and sure enough it adds the metadata to it. I don't remember how that plumbing works, so I'll go resolve that before I commit this.
I've started work on a number of Chirp drivers that don't implement upload/download, but just read a binary file from disk (downloaded with first-party software), modify it, and expect the first-party software to be able to open and upload it back to the radio.
Yeah, so overriding save_mmap() for those cases will work as it does for the CSV driver. As long as the directory is graceful about no metadata and lets the driver's match method have a go at it, we should be fine for drivers like you describe.
--Dan
participants (2)
-
Dan Smith
-
Tom Hayward