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