Add support for registration codes.

BUG=chrome-os-partner:11912
TEST=shopfloor_unittest.py, manual end-to-end test

Change-Id: I14c58e7fc7a546f619155c1cb98709a8559892ec
Reviewed-on: https://gerrit.chromium.org/gerrit/28685
Commit-Ready: Jon Salz <jsalz@chromium.org>
Reviewed-by: Jon Salz <jsalz@chromium.org>
Tested-by: Jon Salz <jsalz@chromium.org>
diff --git a/factory_setup/shopfloor/__init__.py b/factory_setup/shopfloor/__init__.py
index f34a28e..842a2b7 100644
--- a/factory_setup/shopfloor/__init__.py
+++ b/factory_setup/shopfloor/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
+# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
@@ -9,6 +9,8 @@
 functions to interact with their real shop floor system.
 """
 
+import csv
+import logging
 import os
 import time
 import xmlrpclib
@@ -18,18 +20,66 @@
 
 import factory_update_server
 
+
+EVENTS_DIR = 'events'
+REPORTS_DIR = 'reports'
+UPDATE_DIR = 'update'
+REGISTRATION_CODES_CSV = 'registration_codes.csv'
+
+
 class ShopFloorBase(object):
+  """Base class for shopfloor servers.
+
+  Properties:
+    config: The configuration data provided by the '-c' argument to
+      shopfloor_server.
+    data_dir: The top-level directory for shopfloor data.
+  """
+
   NAME = 'ShopFloorBase'
-  VERSION = 2
-  LATEST_MD5SUM_FILENAME = 'latest.md5sum'
+  VERSION = 4
 
-  events_dir = 'events'
+  def _InitBase(self):
+    """Initializes the base class."""
+    self._registration_code_log = open(
+        os.path.join(self.data_dir, REGISTRATION_CODES_CSV), "ab", 0)
+    class Dialect(csv.excel):
+      lineterminator = '\n'
+    self._registration_code_writer = csv.writer(self._registration_code_log,
+                                                dialect=Dialect)
 
-  def __init__(self, config=None):
+    # Put events uploaded from DUT in the "events" directory in data_dir.
+    self._events_dir = os.path.join(self.data_dir, EVENTS_DIR)
+    if not os.path.isdir(self._events_dir):
+      os.mkdir(self._events_dir)
+
+    # Dynamic test directory for holding updates is called "update" in data_dir.
+    update_dir = os.path.join(self.data_dir, UPDATE_DIR)
+    if os.path.exists(update_dir):
+      self.update_dir = os.path.realpath(update_dir)
+      self.update_server = factory_update_server.FactoryUpdateServer(
+          self.update_dir)
+    else:
+      logging.warn('Update directory %s does not exist; '
+                   'disabling update server.', update_dir)
+      self.update_dir = None
+      self.update_server = None
+
+  def _StartBase(self):
+    """Starts the base class."""
+    if self.update_server:
+      logging.debug('Starting factory update server...')
+      self.update_server.Start()
+
+  def _StopBase(self):
+    """Stops the base class."""
+    if self.update_server:
+      self.update_server.Stop()
+
+  def Init(self):
     """Initializes the shop floor system.
 
-    Args:
-      config: String of command line parameter "-c" from server invocation.
+    Subclasses should implement this rather than __init__.
     """
     pass
 
@@ -106,6 +156,26 @@
     """
     raise NotImplementedError('Finalize')
 
+  def GetRegistrationCodeMap(self, serial):
+    """Returns the registration code map for the given serial number.
+
+    Returns:
+      {'user': registration_code, 'group': group_code}
+
+    Raises:
+      ValueError if serial is invalid, or other exceptions defined by individual
+      modules. Note this will be converted to xmlrpclib.Fault when being used as
+      a XML-RPC server module.
+    """
+    raise NotImplementedError('GetRegistrationCode')
+
+  def LogRegistrationCodeMap(self, hwid, registration_code_map):
+    """Logs that a particular registration code has been used."""
+    self._registration_code_writer.writerow(
+        [hwid, registration_code_map['user'], registration_code_map['group'],
+         time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())])
+    os.fdatasync(self._registration_code_log.fileno())
+
   def GetTestMd5sum(self):
     """Gets the latest md5sum of dynamic test tarball.
 
@@ -117,7 +187,7 @@
 
     md5file = os.path.join(self.update_dir,
                            factory_update_server.FACTORY_DIR,
-                           self.LATEST_MD5SUM_FILENAME)
+                           factory_update_server.LATEST_MD5SUM)
     if not os.path.isfile(md5file):
       return None
     with open(md5file, 'r') as f:
@@ -129,7 +199,7 @@
     Returns:
       The port, or None if there is no update server available.
     """
-    return self.update_port
+    return self.update_server.rsyncd_port if self.update_server else None
 
   def UploadEvent(self, log_name, chunk):
     """Uploads a chunk of events.
@@ -147,13 +217,13 @@
     Raises:
       IOError if unable to save the chunk of events.
     """
-    if not os.path.exists(self.events_dir):
-      os.makedirs(self.events_dir)
+    if not os.path.exists(self._events_dir):
+      os.makedirs(self._events_dir)
 
     if isinstance(chunk, Binary):
       chunk = chunk.data
 
-    log_file = os.path.join(self.events_dir, log_name)
+    log_file = os.path.join(self._events_dir, log_name)
     with open(log_file, 'a') as f:
       f.write(chunk)
     return True
diff --git a/factory_setup/shopfloor/simple.py b/factory_setup/shopfloor/simple.py
index 13cd729..e36114b 100644
--- a/factory_setup/shopfloor/simple.py
+++ b/factory_setup/shopfloor/simple.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
+# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
@@ -33,33 +33,25 @@
 
 
 class ShopFloor(shopfloor.ShopFloorBase):
-  """Sample shop floor system, using CSV file as input."""
+  """Sample shop floor system, using CSV file as input.
+
+  Device data is read from a 'devices.csv' file in the data directory.
+  """
   NAME = "CSV-file based shop floor system"
-  VERSION = 3
-  EVENTS_DIR = 'events'
+  VERSION = 4
 
-  def __init__(self, config=None):
-    if not (config and os.path.exists(config)):
-      raise IOError("You must specify an existing CSV file by -c FILE.")
+  def Init(self):
+    devices_csv = os.path.join(self.data_dir, 'devices.csv')
+    logging.info("Parsing %s...", devices_csv)
+    self.data_store = LoadCsvData(devices_csv)
+    logging.info("Loaded %d entries from %s.",
+                 len(self.data_store), devices_csv)
 
-    logging.info("Parsing %s...", config)
-    self.data_store = LoadCsvData(config)
-    logging.info("Loaded %d entries from %s.", len(self.data_store), config)
-
-    # In this implementation, we put uploaded reports in a "reports" folder
-    # where the input source (csv) file exists.
-    self.reports_dir = os.path.join(os.path.realpath(os.path.dirname(config)),
-                                    'reports')
+    # Put uploaded reports in a "reports" folder inside data_dir.
+    self.reports_dir = os.path.join(self.data_dir, 'reports')
     if not os.path.isdir(self.reports_dir):
       os.mkdir(self.reports_dir)
 
-    # Put events uploaded from DUT in the EVENTS_DIR under where the config file
-    # exists.
-    self.events_dir = os.path.join(os.path.realpath(os.path.dirname(config)),
-                                   self.EVENTS_DIR)
-    if not os.path.isdir(self.events_dir):
-      os.mkdir(self.events_dir)
-
     # Try to touch some files inside directory, to make sure the directory is
     # writable, and everything I/O system is working fine.
     stamp_file = os.path.join(self.reports_dir, ".touch")
@@ -83,6 +75,13 @@
     self._CheckSerialNumber(serial)
     return self.data_store[serial]['vpd']
 
+  def GetRegistrationCodeMap(self, serial):
+    self._CheckSerialNumber(serial)
+    registration_code_map = self.data_store[serial]['registration_code_map']
+    self.LogRegistrationCodeMap(self.data_store[serial]['hwid'],
+                                registration_code_map)
+    return registration_code_map
+
   def UploadReport(self, serial, report_blob, report_name=None):
     def is_gzip_blob(blob):
       """Check (not 100% accurate) if input blob is gzipped."""
@@ -112,17 +111,21 @@
   # Required fields.
   KEY_SERIAL_NUMBER = 'serial_number'
   KEY_HWID = 'hwid'
+  KEY_REGISTRATION_CODE_USER = 'registration_code_user'
+  KEY_REGISTRATION_CODE_GROUP = 'registration_code_group'
+
   # Optional fields.
   PREFIX_RO_VPD = 'ro_vpd_'
   PREFIX_RW_VPD = 'rw_vpd_'
   VPD_PREFIXES = (PREFIX_RO_VPD, PREFIX_RW_VPD)
 
   REQUIRED_KEYS = (KEY_SERIAL_NUMBER, KEY_HWID)
+  OPTIONAL_KEYS = (KEY_REGISTRATION_CODE_USER, KEY_REGISTRATION_CODE_GROUP)
   OPTIONAL_PREFIXES = VPD_PREFIXES
 
   def check_field_name(name):
     """Checks if argument is an valid input name."""
-    if name in REQUIRED_KEYS:
+    if name in REQUIRED_KEYS or name in OPTIONAL_KEYS:
       return True
     for prefix in OPTIONAL_PREFIXES:
       if name.startswith(prefix):
@@ -143,6 +146,15 @@
         vpd[key_type][key_name.strip()] = value.strip()
     return vpd
 
+  def build_registration_code_map(source):
+    """Builds registration_code_map structure.
+
+    Returns:
+      A dict containing 'user' and 'group' keys.
+    """
+    return {'user': source.get(KEY_REGISTRATION_CODE_USER),
+            'group': source.get(KEY_REGISTRATION_CODE_GROUP)}
+
   data = {}
   with open(filename, 'rb') as source:
     reader = csv.DictReader(source)
@@ -166,6 +178,8 @@
         if not check_field_name(field):
           raise ValueError("Invalid field: %s" % field)
 
-      entry = {'hwid': hwid, 'vpd': build_vpd(row)}
+      entry = {'hwid': hwid,
+               'vpd': build_vpd(row),
+               'registration_code_map': build_registration_code_map(row)}
       data[serial_number] = entry
   return data
diff --git a/factory_setup/shopfloor_server.py b/factory_setup/shopfloor_server.py
index f9db8c2..053702c 100755
--- a/factory_setup/shopfloor_server.py
+++ b/factory_setup/shopfloor_server.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
+# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
@@ -11,7 +11,7 @@
 module you want to use (modules are located in "shopfloor" subdirectory).
 
 Example:
-  ./shopfloor_server -m shopfloor.sample.SampleShopFloor
+  ./shopfloor_server -m shopfloor.simple.ShopFloor
 '''
 
 
@@ -22,8 +22,6 @@
 import shopfloor
 import SimpleXMLRPCServer
 
-from factory_update_server import FactoryUpdateServer
-
 
 _DEFAULT_SERVER_PORT = 8082
 # By default, this server is supposed to serve on same host running omaha
@@ -71,7 +69,6 @@
 
 def main():
   '''Main entry when being invoked by command line.'''
-
   parser = optparse.OptionParser()
   parser.add_option('-a', '--address', dest='address', metavar='ADDR',
                     default=_DEFAULT_SERVER_ADDRESS,
@@ -81,13 +78,14 @@
                     help='port to bind (default: %default)')
   parser.add_option('-m', '--module', dest='module', metavar='MODULE',
                     default='shopfloor.ShopFloorBase',
-                    help='(required) shop floor system module to load, in '
-                    'PACKAGE.MODULE.CLASS format. Ex: shopfloor.sample.Sample')
+                    help='shop floor system module to load, in '
+                    'PACKAGE.MODULE.CLASS format. Ex: '
+                    'shopfloor.simple.ShopFloor')
   parser.add_option('-c', '--config', dest='config', metavar='CONFIG',
                     help='configuration data for shop floor system')
-  parser.add_option('-u', '--update-dir', dest='update_dir',
-                    metavar='UPDATEDIR',
-                    help='directory that may contain updated factory.tar.bz2')
+  parser.add_option('-d', '--data-dir', dest='data_dir', metavar='DIR',
+                    default=os.path.dirname(os.path.realpath(__file__)),
+                    help='data directory for shop floor system')
   parser.add_option('-v', '--verbose', action='count', dest='verbose',
                     help='increase message verbosity')
   parser.add_option('-q', '--quiet', action='store_true', dest='quiet',
@@ -108,43 +106,31 @@
   log_format += '%(message)s'
   logging.basicConfig(level=verbosity, format=log_format)
   if options.quiet:
-    logging.disable(logging.CRITICAL)
+    logging.disable(logging.INFO)
 
   try:
-    options.module = options.module
     logging.debug('Loading shop floor system module: %s', options.module)
-    instance = _LoadShopFloorModule(options.module)(config=options.config)
+    instance = _LoadShopFloorModule(options.module)()
+
     if not isinstance(instance, shopfloor.ShopFloorBase):
       logging.critical('Module does not inherit ShopFloorBase: %s',
                        options.module)
       exit(1)
+
+    instance.data_dir = options.data_dir
+    instance.config = options.config
+    instance._InitBase()
+    instance.Init()
   except:
     logging.exception('Failed loading module: %s', options.module)
     exit(1)
 
-  if options.update_dir:
-    if not os.path.isdir(options.update_dir):
-      raise IOError("Update directory %s does not exist" % options.update_dir)
-
-    # Dynamic test directory for holding updates.
-    instance.update_dir = os.path.realpath(options.update_dir)
-    update_server = FactoryUpdateServer(instance.update_dir)
-    instance.update_port = update_server.rsyncd_port
-  else:
-    instance.update_dir = None
-    instance.update_port = None
-    update_server = None
-
   try:
-    if update_server:
-      logging.debug('Starting factory update server...')
-      update_server.Start()
-
+    instance._StartBase()
     logging.debug('Starting RPC server...')
     _RunAsServer(address=options.address, port=options.port, instance=instance)
   finally:
-    if update_server:
-      update_server.Stop()
+    instance._StopBase()
 
 
 if __name__ == '__main__':
diff --git a/factory_setup/shopfloor_unittest.py b/factory_setup/shopfloor_unittest.py
index 4c66f5f..f6fad2a 100755
--- a/factory_setup/shopfloor_unittest.py
+++ b/factory_setup/shopfloor_unittest.py
@@ -1,12 +1,13 @@
 #!/usr/bin/env python
 
-# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
+# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
 """Unit tests for shop floor server."""
 
 import os
+import re
 import shutil
 import subprocess
 import sys
@@ -25,16 +26,20 @@
     '''Starts shop floor server and creates client proxy.'''
     self.server_port = shopfloor_server._DEFAULT_SERVER_PORT
     self.base_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
-    self.work_dir = tempfile.mkdtemp(prefix='sft')
+    self.data_dir = tempfile.mkdtemp(prefix='shopfloor_data.')
+    self.registration_code_log = (
+        os.path.join(self.data_dir, shopfloor.REGISTRATION_CODES_CSV))
     csv_source = os.path.join(self.base_dir, 'testdata', 'shopfloor',
-                              'simple.csv')
-    csv_work = os.path.join(self.work_dir, 'simple.csv')
+                              'devices.csv')
+    csv_work = os.path.join(self.data_dir, 'devices.csv')
     shutil.copyfile(csv_source, csv_work)
+    os.mkdir(os.path.join(self.data_dir, shopfloor.UPDATE_DIR))
+    os.mkdir(os.path.join(self.data_dir, shopfloor.UPDATE_DIR, 'factory'))
 
     cmd = ['python', os.path.join(self.base_dir, 'shopfloor_server.py'),
            '-q', '-a', 'localhost', '-p', str(self.server_port),
-           '-m', 'shopfloor.simple.ShopFloor', '-c', csv_work,
-           '-t', self.work_dir]
+           '-m', 'shopfloor.simple.ShopFloor',
+           '-d', self.data_dir]
     self.process = subprocess.Popen(cmd)
     self.proxy = xmlrpclib.ServerProxy('http://localhost:%s' % self.server_port,
                                        allow_none=True)
@@ -42,15 +47,18 @@
     for i in xrange(10):
       try:
         self.proxy.Ping()
+        break
       except:
         time.sleep(0.1)
         continue
+    else:
+      self.fail('Server never came up')
 
   def tearDown(self):
     '''Terminates shop floor server'''
     self.process.terminate()
     self.process.wait()
-    shutil.rmtree(self.work_dir)
+    shutil.rmtree(self.data_dir)
 
   def testGetHWID(self):
     # Valid HWIDs range from CR001001 to CR001025
@@ -94,7 +102,7 @@
     self.assertEqual(vpd['ro']['initial_locale'], 'nl')
     self.assertEqual(vpd['ro']['initial_timezone'], 'Europe/Amsterdam')
     self.assertEqual(vpd['rw']['wifi_mac'], '0b:ad:f0:0d:15:10')
-    self.assertTrue('cellular_mac' not in vpd['rw'])
+    self.assertEqual(vpd['rw']['cellular_mac'], '')
 
     # Checks MAC addresses
     for i in range(25):
@@ -114,7 +122,8 @@
     # Upload simple blob
     blob = 'Simple Blob'
     report_name = 'simple_blob.rpt'
-    report_path = os.path.join(self.work_dir, 'reports', report_name)
+    report_path = os.path.join(self.data_dir, shopfloor.REPORTS_DIR,
+                               report_name)
     self.proxy.UploadReport('CR001020', shopfloor.Binary('Simple Blob'),
                             report_name)
     self.assertTrue(os.path.exists(report_path))
@@ -128,10 +137,10 @@
     self.assertRaises(xmlrpclib.Fault, self.proxy.Finalize, '0999')
 
   def testGetTestMd5sum(self):
-    md5_source = os.path.join(self.base_dir, 'testdata', 'shopfloor',
-                              'latest.md5sum')
-    md5_work = os.path.join(self.work_dir, 'latest.md5sum')
-    shutil.copyfile(md5_source, md5_work)
+    md5_work = os.path.join(self.data_dir, shopfloor.UPDATE_DIR,
+                            'factory', 'latest.md5sum')
+    with open(md5_work, "w") as f:
+      f.write('0891a16c456fcc322b656d5f91fbf060')
     self.assertEqual(self.proxy.GetTestMd5sum(),
                      '0891a16c456fcc322b656d5f91fbf060')
     os.remove(md5_work)
@@ -139,9 +148,27 @@
   def testGetTestMd5sumWithoutMd5sumFile(self):
     self.assertTrue(self.proxy.GetTestMd5sum() is None)
 
+  def testGetRegistrationCodeMap(self):
+    self.assertEquals(
+        {'user': ('000000000000000000000000000000000000'
+                  '0000000000000000000000000000190a55ad'),
+         'group': ('010101010101010101010101010101010101'
+                   '010101010101010101010101010162319fcc')},
+        self.proxy.GetRegistrationCodeMap('CR001001'))
+
+    # Make sure it was logged.
+    log = open(self.registration_code_log).read()
+    self.assertTrue(re.match(
+        '^MAGICA MADOKA A-A 1214,'
+        '000000000000000000000000000000000000'
+        '0000000000000000000000000000190a55ad,'
+        '010101010101010101010101010101010101'
+        '010101010101010101010101010162319fcc,'
+        '\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d\n', log), repr(log))
+
   def testUploadEvent(self):
     # Check if events dir is created.
-    events_dir = os.path.join(self.work_dir, 'events')
+    events_dir = os.path.join(self.data_dir, shopfloor.EVENTS_DIR)
     self.assertTrue(os.path.isdir(events_dir))
 
     # A new event file should be created.
diff --git a/factory_setup/testdata/shopfloor/devices.csv b/factory_setup/testdata/shopfloor/devices.csv
new file mode 100644
index 0000000..11b1083
--- /dev/null
+++ b/factory_setup/testdata/shopfloor/devices.csv
@@ -0,0 +1,27 @@
+serial_number,hwid,ro_vpd_serial_number,ro_vpd_keyboard_layout,ro_vpd_initial_locale,ro_vpd_initial_timezone,rw_vpd_wifi_mac,rw_vpd_cellular_mac,registration_code_user,registration_code_group
+CR001001,MAGICA MADOKA A-A 1214,CR001001,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:01,70:75:65:6c:6c:61,0000000000000000000000000000000000000000000000000000000000000000190a55ad,010101010101010101010101010101010101010101010101010101010101010162319fcc
+CR001002,MAGICA MADOKA A-A 1214,CR001002,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:02,70:75:65:6c:6c:62,0202020202020202020202020202020202020202020202020202020202020202ef7dc16f,030303030303030303030303030303030303030303030303030303030303030394460b0e
+CR001003,MAGICA MADOKA A-A 1214,CR001003,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:03,70:75:65:6c:6c:63,04040404040404040404040404040404040404040404040404040404040404042e947a68,050505050505050505050505050505050505050505050505050505050505050555afb009
+CR001004,MAGICA MADOKA A-A 1214,CR001004,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:04,70:75:65:6c:6c:64,0606060606060606060606060606060606060606060606060606060606060606d8e3eeaa,0707070707070707070707070707070707070707070707070707070707070707a3d824cb
+CR001005,MAGICA MADOKA A-A 1214,CR001005,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:05,70:75:65:6c:6c:65,080808080808080808080808080808080808080808080808080808080808080876360a27,09090909090909090909090909090909090909090909090909090909090909090d0dc046
+CR001006,MAGICA HOMURA A-A 7056,CR001006,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:06,0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a80419ee5,0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bfb7a5484
+CR001007,MAGICA HOMURA A-A 7056,CR001007,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:07,,0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c41a825e2,0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d3a93ef83
+CR001008,MAGICA HOMURA B-A 8265,CR001008,xkb:de::ger,de-DE,Europe/Amsterdam,0b:ad:f0:0d:15:08,,0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0eb7dfb120,0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0fcce47b41
+CR001009,MAGICA HOMURA B-A 8265,CR001009,xkb:de::ger,de-DE,Europe/Amsterdam,0b:ad:f0:0d:15:09,,1010101010101010101010101010101010101010101010101010101010101010c772eab9,1111111111111111111111111111111111111111111111111111111111111111bc4920d8
+CR001010,MAGICA HOMURA C-A 2974,CR001010,xkb:fr::fra,fr-FR,Europe/Amsterdam,0b:ad:f0:0d:15:0a,,121212121212121212121212121212121212121212121212121212121212121231057e7b,13131313131313131313131313131313131313131313131313131313131313134a3eb41a
+CR001011,MAGICA SAYAKA A-A 2258,CR001011,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0b,,1414141414141414141414141414141414141414141414141414141414141414f0ecc57c,15151515151515151515151515151515151515151515151515151515151515158bd70f1d
+CR001012,MAGICA SAYAKA A-B 3304,CR001012,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0c,,1616161616161616161616161616161616161616161616161616161616161616069b51be,17171717171717171717171717171717171717171717171717171717171717177da09bdf
+CR001013,MAGICA SAYAKA A-C 2142,CR001013,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0d,,1818181818181818181818181818181818181818181818181818181818181818a84eb533,1919191919191919191919191919191919191919191919191919191919191919d3757f52
+CR001014,MAGICA SAYAKA A-D 3565,CR001014,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0e,,1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a5e3921f1,1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b2502eb90
+CR001015,MAGICA SAYAKA A-E 1787,CR001015,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0f,,1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c9fd09af6,1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1de4eb5097
+CR001016,MAGICA MAMI A-A 2443,CR001016,xkb:us:intl:eng,nl,Europe/Amsterdam,0b:ad:f0:0d:15:10,,1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e69a70e34,1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f129cc455
+CR001017,MAGICA MAMI A-A 2443,CR001017,xkb:us:intl:eng,nl,Europe/Amsterdam,0b:ad:f0:0d:15:11,,20202020202020202020202020202020202020202020202020202020202020207e8a2dc4,212121212121212121212121212121212121212121212121212121212121212105b1e7a5
+CR001018,MAGICA MAMI A-A 2443,CR001018,xkb:us:intl:eng,zh-TW,Asia/Taipei,0b:ad:f0:0d:15:12,,222222222222222222222222222222222222222222222222222222222222222288fdb906,2323232323232323232323232323232323232323232323232323232323232323f3c67367
+CR001019,MAGICA MAMI A-A 2443,CR001019,xkb:us:intl:eng,zh-TW,Asia/Taipei,0b:ad:f0:0d:15:13,,242424242424242424242424242424242424242424242424242424242424242449140201,2525252525252525252525252525252525252525252525252525252525252525322fc860
+CR001020,MAGICA MAMI A-A 2443,CR001020,xkb:us:intl:eng,zh-TW,Asia/Taipei,0b:ad:f0:0d:15:14,,2626262626262626262626262626262626262626262626262626262626262626bf6396c3,2727272727272727272727272727272727272727272727272727272727272727c4585ca2
+CR001021,MAGICA KYOKO AA-A 0136,CR001021,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:15,,282828282828282828282828282828282828282828282828282828282828282811b6724e,29292929292929292929292929292929292929292929292929292929292929296a8db82f
+CR001022,MAGICA KYOKO AA-A 0136,CR001022,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:16,,2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2ae7c1e68c,2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b9cfa2ced
+CR001023,MAGICA KYOKO AB-A 9345,CR001023,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:17,,2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c26285d8b,2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d5d1397ea
+CR001024,MAGICA KYOKO AC-A 3910,CR001024,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:18,,2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2ed05fc949,2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2f2fab640328
+CR001025,MAGICA KYOKO AD-A 3779,CR001025,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:19,,3030303030303030303030303030303030303030303030303030303030303030a0f292d0,3131313131313131313131313131313131313131313131313131313131313131dbc958b1
+link,LINK PROTO A-A 0569,CR000102,xkb:us::eng,en-US,America/Los_Angeles,01:23:45:67:89:ab,12:34:56:78:9a:bc,323232323232323232323232323232323232323232323232323232323232323256850612,33333333333333333333333333333333333333333333333333333333333333332dbecc73
diff --git a/factory_setup/testdata/shopfloor/simple.csv b/factory_setup/testdata/shopfloor/simple.csv
deleted file mode 100644
index decc46a..0000000
--- a/factory_setup/testdata/shopfloor/simple.csv
+++ /dev/null
@@ -1,27 +0,0 @@
-serial_number,hwid,ro_vpd_serial_number,ro_vpd_keyboard_layout,ro_vpd_initial_locale,ro_vpd_initial_timezone,rw_vpd_wifi_mac,rw_vpd_cellular_mac
-CR001001,MAGICA MADOKA A-A 1214,CR001001,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:01,70:75:65:6c:6c:61
-CR001002,MAGICA MADOKA A-A 1214,CR001002,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:02,70:75:65:6c:6c:62
-CR001003,MAGICA MADOKA A-A 1214,CR001003,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:03,70:75:65:6c:6c:63
-CR001004,MAGICA MADOKA A-A 1214,CR001004,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:04,70:75:65:6c:6c:64
-CR001005,MAGICA MADOKA A-A 1214,CR001005,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:05,70:75:65:6c:6c:65
-CR001006,MAGICA HOMURA A-A 7056,CR001006,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:06
-CR001007,MAGICA HOMURA A-A 7056,CR001007,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:07
-CR001008,MAGICA HOMURA B-A 8265,CR001008,xkb:de::ger,de-DE,Europe/Amsterdam,0b:ad:f0:0d:15:08
-CR001009,MAGICA HOMURA B-A 8265,CR001009,xkb:de::ger,de-DE,Europe/Amsterdam,0b:ad:f0:0d:15:09
-CR001010,MAGICA HOMURA C-A 2974,CR001010,xkb:fr::fra,fr-FR,Europe/Amsterdam,0b:ad:f0:0d:15:0a
-CR001011,MAGICA SAYAKA A-A 2258,CR001011,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0b
-CR001012,MAGICA SAYAKA A-B 3304,CR001012,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0c
-CR001013,MAGICA SAYAKA A-C 2142,CR001013,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0d
-CR001014,MAGICA SAYAKA A-D 3565,CR001014,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0e
-CR001015,MAGICA SAYAKA A-E 1787,CR001015,xkb:us::eng,en-US,America/Los_Angeles,0b:ad:f0:0d:15:0f
-CR001016,MAGICA MAMI A-A 2443,CR001016,xkb:us:intl:eng,nl,Europe/Amsterdam,0b:ad:f0:0d:15:10
-CR001017,MAGICA MAMI A-A 2443,CR001017,xkb:us:intl:eng,nl,Europe/Amsterdam,0b:ad:f0:0d:15:11
-CR001018,MAGICA MAMI A-A 2443,CR001018,xkb:us:intl:eng,zh-TW,Asia/Taipei,0b:ad:f0:0d:15:12
-CR001019,MAGICA MAMI A-A 2443,CR001019,xkb:us:intl:eng,zh-TW,Asia/Taipei,0b:ad:f0:0d:15:13
-CR001020,MAGICA MAMI A-A 2443,CR001020,xkb:us:intl:eng,zh-TW,Asia/Taipei,0b:ad:f0:0d:15:14
-CR001021,MAGICA KYOKO AA-A 0136,CR001021,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:15
-CR001022,MAGICA KYOKO AA-A 0136,CR001022,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:16
-CR001023,MAGICA KYOKO AB-A 9345,CR001023,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:17
-CR001024,MAGICA KYOKO AC-A 3910,CR001024,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:18
-CR001025,MAGICA KYOKO AD-A 3779,CR001025,xkb:jp::jpn,ja,Asia/Tokyo,0b:ad:f0:0d:15:19
-link,LINK PROTO A-A 0569,CR000102,xkb:us::eng,en-US,America/Los_Angeles,01:23:45:67:89:ab,12:34:56:78:9a:bc