|
@@ -0,0 +1,416 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""
|
|
|
|
|
+simulator/main.py
|
|
|
|
|
+
|
|
|
|
|
+Device simulator cho POC — chạy từ đầu: claim → approve → heartbeat + capture upload.
|
|
|
|
|
+
|
|
|
|
|
+Config: /etc/timelapse/simulator.env
|
|
|
|
|
+ SIM_SERVER_URL=http://localhost:3001
|
|
|
|
|
+ SIM_DEVICE_COUNT=2
|
|
|
|
|
+ SIM_CAPTURE_INTERVAL=60 # seconds between captures
|
|
|
|
|
+ SIM_PROJECT_ID= # optional: project ID to assign (auto-detect if empty)
|
|
|
|
|
+ SIM_ORG_ID= # optional: org ID (auto-detect if empty)
|
|
|
|
|
+
|
|
|
|
|
+Luồng:
|
|
|
|
|
+1. Đọc /etc/machine-id hoặc sinh UUID cố định
|
|
|
|
|
+2. Claim device → lấy claim code
|
|
|
|
|
+3. Auto-approve: gọi internal API bằng seed admin token
|
|
|
|
|
+ (Đỡ phải manual approve trên dashboard)
|
|
|
|
|
+4. Poll /v1/devices/claim/:code/status để lấy bootstrap API key
|
|
|
|
|
+5. Heartbeat loop (cứ 30s)
|
|
|
|
|
+6. Capture loop (cứ SIM_CAPTURE_INTERVAL):
|
|
|
|
|
+ - Tạo ảnh placeholder nhẹ bằng Pillow
|
|
|
|
|
+ - Upload lên /v1/captures/upload
|
|
|
|
|
+ - Gửi heartbeat cập nhật capturesToday
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import hashlib
|
|
|
|
|
+import io
|
|
|
|
|
+import json
|
|
|
|
|
+import os
|
|
|
|
|
+import random
|
|
|
|
|
+import string
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
+
|
|
|
|
|
+import requests
|
|
|
|
|
+
|
|
|
|
|
+# ── Config ──────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+SERVER_URL = os.environ.get('SIM_SERVER_URL', 'http://localhost:3001').rstrip('/')
|
|
|
|
|
+DEVICE_COUNT = int(os.environ.get('SIM_DEVICE_COUNT', '2'))
|
|
|
|
|
+CAPTURE_INTERVAL = int(os.environ.get('SIM_CAPTURE_INTERVAL', '60'))
|
|
|
|
|
+PROJECT_ID = os.environ.get('SIM_PROJECT_ID', '') or None
|
|
|
|
|
+ORG_ID = os.environ.get('SIM_ORG_ID', '') or None
|
|
|
|
|
+
|
|
|
|
|
+# Internal secret để auto-approve device (không cần org/user context)
|
|
|
|
|
+SIM_INTERNAL_KEY = os.environ.get('SIM_INTERNAL_KEY', 'sim-internal-dev-key-123')
|
|
|
|
|
+
|
|
|
|
|
+CONFIG_DIR = Path(os.environ.get('SIM_CONFIG_DIR', '/etc/timelapse'))
|
|
|
|
|
+CONFIG_FILE = CONFIG_DIR / 'simulator.env'
|
|
|
|
|
+DEVICE_DIR = Path(os.environ.get('SIM_DEVICE_DIR', '/etc/timelapse/devices'))
|
|
|
|
|
+
|
|
|
|
|
+# ── Logging ─────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def log(msg: str) -> None:
|
|
|
|
|
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
+
|
|
|
|
|
+def log_err(msg: str) -> None:
|
|
|
|
|
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] ERROR: {msg}", flush=True, file=sys.stderr)
|
|
|
|
|
+
|
|
|
|
|
+# ── Utilities ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def get_machine_id() -> str:
|
|
|
|
|
+ """Read /etc/machine-id for stable device UUID."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open('/etc/machine-id', 'r') as f:
|
|
|
|
|
+ return f.read().strip()
|
|
|
|
|
+ except FileNotFoundError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ # Fallback: generate once and save
|
|
|
|
|
+ fallback = 'sim-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
|
|
|
|
+ DEVICE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ id_file = DEVICE_DIR / '.machine_id'
|
|
|
|
|
+ if id_file.exists():
|
|
|
|
|
+ return id_file.read_text().strip()
|
|
|
|
|
+ id_file.write_text(fallback)
|
|
|
|
|
+ return fallback
|
|
|
|
|
+
|
|
|
|
|
+def random_uuid(prefix: str = 'sim') -> str:
|
|
|
|
|
+ return f"{prefix}-" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
|
|
|
|
+
|
|
|
|
|
+def generate_claim_code() -> str:
|
|
|
|
|
+ chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
|
|
|
+ return ''.join(random.choices(chars, k=6))
|
|
|
|
|
+
|
|
|
|
|
+# ── Placeholder image generator ─────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def generate_placeholder_image(device_id: str, seq: int, width=640, height=480) -> bytes:
|
|
|
|
|
+ """Generate a small JPEG placeholder with device info overlay."""
|
|
|
|
|
+ img = Image.new('RGB', (width, height), color=(
|
|
|
|
|
+ random.randint(80, 120), # R
|
|
|
|
|
+ random.randint(120, 160), # G
|
|
|
|
|
+ random.randint(160, 200), # B
|
|
|
|
|
+ ))
|
|
|
|
|
+ draw = ImageDraw.Draw(img)
|
|
|
|
|
+
|
|
|
|
|
+ # Watermark text
|
|
|
|
|
+ now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
+ lines = [
|
|
|
|
|
+ f"Device: {device_id[:16]}",
|
|
|
|
|
+ f"Seq: {seq:04d}",
|
|
|
|
|
+ f"Time: {now}",
|
|
|
|
|
+ f"Simulator v1.0",
|
|
|
|
|
+ ]
|
|
|
|
|
+ try:
|
|
|
|
|
+ # Try to use a basic font, fallback to default
|
|
|
|
|
+ font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 18)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ font = ImageFont.load_default()
|
|
|
|
|
+
|
|
|
|
|
+ y = 20
|
|
|
|
|
+ for line in lines:
|
|
|
|
|
+ draw.text((10, y), line, fill=(255, 255, 255), font=font)
|
|
|
|
|
+ y += 26
|
|
|
|
|
+
|
|
|
|
|
+ # Add a small colored rectangle in corner (visual variety)
|
|
|
|
|
+ for _ in range(3):
|
|
|
|
|
+ rx = random.randint(0, width - 60)
|
|
|
|
|
+ ry = random.randint(0, height - 40)
|
|
|
|
|
+ rw = random.randint(30, 60)
|
|
|
|
|
+ rh = random.randint(20, 40)
|
|
|
|
|
+ color = (random.randint(0, 200), random.randint(0, 200), random.randint(0, 200))
|
|
|
|
|
+ draw.rectangle([rx, ry, rx + rw, ry + rh], fill=color)
|
|
|
|
|
+
|
|
|
|
|
+ buf = io.BytesIO()
|
|
|
|
|
+ # Quality 60 = small file, still recognizable
|
|
|
|
|
+ img.save(buf, format='JPEG', quality=60, optimize=True)
|
|
|
|
|
+ return buf.getvalue()
|
|
|
|
|
+
|
|
|
|
|
+# ── Device lifecycle ────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+class SimulatedDevice:
|
|
|
|
|
+ def __init__(self, index: int, server_url: str):
|
|
|
|
|
+ self.index = index
|
|
|
|
|
+ self.server_url = server_url
|
|
|
|
|
+ self.device_id: str | None = None
|
|
|
|
|
+ self.api_key: str | None = None
|
|
|
|
|
+ self.claim_code: str | None = None
|
|
|
|
|
+ self.capture_seq: int = 0
|
|
|
|
|
+
|
|
|
|
|
+ # Unique per device instance
|
|
|
|
|
+ machine_id = get_machine_id()
|
|
|
|
|
+ base = hashlib.sha256(f"{machine_id}-{index}".encode()).hexdigest()[:16]
|
|
|
|
|
+ self.device_uuid = f"sim-{base}"
|
|
|
|
|
+ self.device_name = f"Sim-Cam-{index+1:02d}"
|
|
|
|
|
+ self.serial_no = f"SIM-{index+1:03d}"
|
|
|
|
|
+
|
|
|
|
|
+ # ── Step 1: Claim ───────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def claim(self) -> bool:
|
|
|
|
|
+ payload = {
|
|
|
|
|
+ 'deviceUuid': self.device_uuid,
|
|
|
|
|
+ 'deviceName': self.device_name,
|
|
|
|
|
+ 'serialNo': self.serial_no,
|
|
|
|
|
+ }
|
|
|
|
|
+ try:
|
|
|
|
|
+ r = requests.post(f"{self.server_url}/v1/devices/claim", json=payload, timeout=10)
|
|
|
|
|
+ if not r.ok:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] claim failed: {r.status_code} {r.text[:120]}")
|
|
|
|
|
+ return False
|
|
|
|
|
+ data = r.json()
|
|
|
|
|
+ self.claim_code = data.get('claimCode')
|
|
|
|
|
+ if not self.claim_code:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] no claimCode in response: {data}")
|
|
|
|
|
+ return False
|
|
|
|
|
+ log(f"[{self.serial_no}] claimed | code={self.claim_code}")
|
|
|
|
|
+ return True
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] claim error: {e}")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # ── Step 2: Auto-approve ───────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def _auto_approve(self) -> bool:
|
|
|
|
|
+ """Approve device using internal key (no org context needed)."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ r = requests.post(
|
|
|
|
|
+ f"{self.server_url}/v1/devices/pending/{self.claim_code}/approve-internal",
|
|
|
|
|
+ json={'projectId': PROJECT_ID},
|
|
|
|
|
+ headers={'X-Internal-Key': SIM_INTERNAL_KEY, 'Content-Type': 'application/json'},
|
|
|
|
|
+ timeout=10,
|
|
|
|
|
+ )
|
|
|
|
|
+ if r.ok:
|
|
|
|
|
+ log(f"[{self.serial_no}] auto-approved (internal)")
|
|
|
|
|
+ return True
|
|
|
|
|
+ else:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] approve failed: {r.status_code} {r.text[:120]}")
|
|
|
|
|
+ return False
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] approve error: {e}")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # ── Step 3: Poll for bootstrap API key ────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def poll_bootstrap_key(self, max_wait: int = 60) -> bool:
|
|
|
|
|
+ if not self.claim_code:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] no claim code to poll")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ log(f"[{self.serial_no}] polling claim status (max {max_wait}s)...")
|
|
|
|
|
+ deadline = time.time() + max_wait
|
|
|
|
|
+
|
|
|
|
|
+ while time.time() < deadline:
|
|
|
|
|
+ try:
|
|
|
|
|
+ r = requests.get(
|
|
|
|
|
+ f"{self.server_url}/v1/devices/claim/{self.claim_code}/status",
|
|
|
|
|
+ timeout=10,
|
|
|
|
|
+ )
|
|
|
|
|
+ if not r.ok:
|
|
|
|
|
+ log(f"[{self.serial_no}] poll: {r.status_code}")
|
|
|
|
|
+ time.sleep(5)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ data = r.json()
|
|
|
|
|
+ status = data.get('status')
|
|
|
|
|
+
|
|
|
|
|
+ if status == 'approved':
|
|
|
|
|
+ self.api_key = data.get('apiKey')
|
|
|
|
|
+ self.device_id = data.get('deviceId')
|
|
|
|
|
+ if self.api_key:
|
|
|
|
|
+ log(f"[{self.serial_no}] APPROVED | deviceId={self.device_id} | apiKey={self.api_key[:8]}...")
|
|
|
|
|
+ return True
|
|
|
|
|
+ else:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] approved but no apiKey in response")
|
|
|
|
|
+ return False
|
|
|
|
|
+ elif status in ('rejected', 'expired'):
|
|
|
|
|
+ log_err(f"[{self.serial_no}] claim {status}")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ time.sleep(5)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] poll error: {e}")
|
|
|
|
|
+ time.sleep(5)
|
|
|
|
|
+
|
|
|
|
|
+ log_err(f"[{self.serial_no}] bootstrap key poll timed out")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # ── Step 4: Heartbeat ──────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def heartbeat(self) -> bool:
|
|
|
|
|
+ if not self.device_id or not self.api_key:
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ payload = {
|
|
|
|
|
+ 'deviceId': self.device_id,
|
|
|
|
|
+ 'status': random.choice(['online', 'online', 'online', 'degraded']),
|
|
|
|
|
+ 'tempC': round(random.uniform(38, 62), 1),
|
|
|
|
|
+ 'batteryPct': random.choice([None, random.randint(30, 100)]),
|
|
|
|
|
+ 'storageFreeGb': random.randint(8, 120),
|
|
|
|
|
+ 'capturesToday': self.capture_seq,
|
|
|
|
|
+ 'lastCaptureAt': datetime.utcnow().isoformat(),
|
|
|
|
|
+ 'firmwareVersion': 'sim-1.0.0',
|
|
|
|
|
+ 'networkStatus': random.choice(['online', 'online', 'degraded']),
|
|
|
|
|
+ }
|
|
|
|
|
+ try:
|
|
|
|
|
+ r = requests.post(
|
|
|
|
|
+ f"{self.server_url}/v1/devices/{self.device_id}/heartbeat",
|
|
|
|
|
+ json=payload,
|
|
|
|
|
+ headers={'X-API-Key': self.api_key, 'Content-Type': 'application/json'},
|
|
|
|
|
+ timeout=10,
|
|
|
|
|
+ )
|
|
|
|
|
+ if r.ok:
|
|
|
|
|
+ data = r.json()
|
|
|
|
|
+ pending = data.get('pendingCommands', 0)
|
|
|
|
|
+ log(f"[{self.serial_no}] hb ok | seq={self.capture_seq} | pending_cmds={pending}")
|
|
|
|
|
+ return True
|
|
|
|
|
+ else:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] hb fail: {r.status_code} {r.text[:80]}")
|
|
|
|
|
+ return False
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] hb err: {e}")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # ── Step 5: Capture + Upload ──────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def capture_and_upload(self) -> bool:
|
|
|
|
|
+ if not self.device_id or not self.api_key:
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ self.capture_seq += 1
|
|
|
|
|
+ captured_at = datetime.utcnow().isoformat()
|
|
|
|
|
+
|
|
|
|
|
+ # Generate image
|
|
|
|
|
+ img_bytes = generate_placeholder_image(self.device_id or self.serial_no, self.capture_seq)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ files = {'file': (f'cap_{self.capture_seq:04d}.jpg', img_bytes, 'image/jpeg')}
|
|
|
|
|
+ data = {
|
|
|
|
|
+ 'deviceId': self.device_id,
|
|
|
|
|
+ 'capturedAt': captured_at,
|
|
|
|
|
+ 'resolution': '640x480',
|
|
|
|
|
+ 'exposureMs': str(random.randint(10, 100)),
|
|
|
|
|
+ 'iso': str(random.choice([100, 200, 400, 800])),
|
|
|
|
|
+ 'aperture': random.choice(['f/2.8', 'f/4', 'f/5.6', 'f/8']),
|
|
|
|
|
+ }
|
|
|
|
|
+ r = requests.post(
|
|
|
|
|
+ f"{self.server_url}/v1/captures/upload",
|
|
|
|
|
+ files=files,
|
|
|
|
|
+ data=data,
|
|
|
|
|
+ headers={'X-API-Key': self.api_key},
|
|
|
|
|
+ timeout=30,
|
|
|
|
|
+ )
|
|
|
|
|
+ if r.ok:
|
|
|
|
|
+ resp = r.json()
|
|
|
|
|
+ file_key = resp.get('fileKey', '?')
|
|
|
|
|
+ size_kb = resp.get('sizeBytes', 0) // 1024
|
|
|
|
|
+ log(f"[{self.serial_no}] uploaded #{self.capture_seq} | {size_kb}KB | key={file_key[:40]}")
|
|
|
|
|
+ return True
|
|
|
|
|
+ else:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] upload fail: {r.status_code} {r.text[:120]}")
|
|
|
|
|
+ return False
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log_err(f"[{self.serial_no}] upload err: {e}")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # ── Full provisioning + loop ───────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def run(self) -> bool:
|
|
|
|
|
+ log(f"[{self.serial_no}] Starting simulator...")
|
|
|
|
|
+
|
|
|
|
|
+ # 1. Claim
|
|
|
|
|
+ if not self.claim():
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # 2. Auto-approve (if admin token available)
|
|
|
|
|
+ if SIM_INTERNAL_KEY:
|
|
|
|
|
+ self._auto_approve()
|
|
|
|
|
+
|
|
|
|
|
+ # 3. Poll bootstrap key
|
|
|
|
|
+ if not self.poll_bootstrap_key(max_wait=60):
|
|
|
|
|
+ log_err(f"[{self.serial_no}] Failed to get bootstrap key — will retry claim")
|
|
|
|
|
+ # Re-claim (get fresh code)
|
|
|
|
|
+ if not self.claim():
|
|
|
|
|
+ return False
|
|
|
|
|
+ if SIM_INTERNAL_KEY:
|
|
|
|
|
+ self._auto_approve()
|
|
|
|
|
+ if not self.poll_bootstrap_key(max_wait=30):
|
|
|
|
|
+ log_err(f"[{self.serial_no}] Giving up on provisioning")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # 4. Loops
|
|
|
|
|
+ heartbeat_count = 0
|
|
|
|
|
+ last_capture = 0
|
|
|
|
|
+
|
|
|
|
|
+ while True:
|
|
|
|
|
+ now = time.time()
|
|
|
|
|
+
|
|
|
|
|
+ # Heartbeat every 30s
|
|
|
|
|
+ if now - heartbeat_count * 30 >= 30 or heartbeat_count == 0:
|
|
|
|
|
+ self.heartbeat()
|
|
|
|
|
+ heartbeat_count += 1
|
|
|
|
|
+
|
|
|
|
|
+ # Capture every CAPTURE_INTERVAL seconds
|
|
|
|
|
+ if now - last_capture >= CAPTURE_INTERVAL:
|
|
|
|
|
+ self.capture_and_upload()
|
|
|
|
|
+ last_capture = now
|
|
|
|
|
+
|
|
|
|
|
+ time.sleep(5) # check every 5s
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Auto-detect org/project for auto-approve ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def detect_org_project() -> tuple[str | None, str | None]:
|
|
|
|
|
+ """Try to auto-detect first org/project from the API (no auth needed for public endpoints)."""
|
|
|
|
|
+ # Without auth, we can't list orgs — just return None
|
|
|
|
|
+ # The approve endpoint will fail, simulator will wait for manual approval
|
|
|
|
|
+ return None, None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Main ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def main() -> None:
|
|
|
|
|
+ log(f"Simulator starting | server={SERVER_URL} | devices={DEVICE_COUNT} | interval={CAPTURE_INTERVAL}s")
|
|
|
|
|
+
|
|
|
|
|
+ devices: list[SimulatedDevice] = []
|
|
|
|
|
+ for i in range(DEVICE_COUNT):
|
|
|
|
|
+ d = SimulatedDevice(i, SERVER_URL)
|
|
|
|
|
+ devices.append(d)
|
|
|
|
|
+
|
|
|
|
|
+ # Run all devices concurrently (threads)
|
|
|
|
|
+ import threading
|
|
|
|
|
+
|
|
|
|
|
+ def run_device(d: SimulatedDevice) -> None:
|
|
|
|
|
+ while True:
|
|
|
|
|
+ try:
|
|
|
|
|
+ d.run()
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log_err(f"[{d.serial_no}] unexpected error: {e}")
|
|
|
|
|
+ log(f"[{d.serial_no}] restarting in 10s...")
|
|
|
|
|
+ time.sleep(10)
|
|
|
|
|
+
|
|
|
|
|
+ threads = [threading.Thread(target=run_device, args=(d,), daemon=True) for d in devices]
|
|
|
|
|
+
|
|
|
|
|
+ for t in threads:
|
|
|
|
|
+ t.start()
|
|
|
|
|
+ time.sleep(1) # stagger to avoid thundering herd
|
|
|
|
|
+
|
|
|
|
|
+ log(f"All {DEVICE_COUNT} device simulators running")
|
|
|
|
|
+
|
|
|
|
|
+ # Keep main thread alive
|
|
|
|
|
+ try:
|
|
|
|
|
+ while True:
|
|
|
|
|
+ time.sleep(60)
|
|
|
|
|
+ except KeyboardInterrupt:
|
|
|
|
|
+ log("Shutting down...")
|
|
|
|
|
+ sys.exit(0)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == '__main__':
|
|
|
|
|
+ main()
|