Update Auto-Qual WebUI to allow firmware version specification

Testing instructions:
1. Test in conjunction with RPC and proto changes.
2. Go to auto-qual > schedule test effort.
3. Schedule an avs/firmware testplan run and specify firmware versions for the test.
4. Verify the firmware versions show up correctly on the scheduling backend.

Change-Id: I716c2287e9458003fed2c54c194b9aba14653bb2
Bug: b:559775353
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/satlab/+/8619772
Reviewed-by: Rafał Gaweł <rafalgawel@google.com>
Tested-by: Kuba Birecki <kbirecki@google.com>
Auto-Submit: Kuba Birecki <kbirecki@google.com>
Commit-Queue: Kuba Birecki <kbirecki@google.com>
diff --git a/src/satlab-ui/src/app/protos/satlabrpc.proto b/src/satlab-ui/src/app/protos/satlabrpc.proto
index 87b68bb..997f768 100644
--- a/src/satlab-ui/src/app/protos/satlabrpc.proto
+++ b/src/satlab-ui/src/app/protos/satlabrpc.proto
@@ -1323,7 +1323,7 @@
   string id = 1;
 }
 
-// NEXT_TAG = 13
+// NEXT_TAG = 14
 message TestEffort {
 
   // NEXT_TAG = 10
@@ -1403,6 +1403,68 @@
     PoolType type = 2;
   }
 
+  // NEXT_TAG = 4
+  // Represents the version of a firmware target.
+  message FirmwareTestEffortVersion {
+    // Required. The major version of the firmware.
+    int32 major_version = 1;
+
+    // Required. The minor version of the firmware.
+    int32 minor_version = 2;
+
+    // Required. The patch number of the firmware.
+    int32 patch_number = 3;
+  }
+
+  // NEXT_TAG = 4
+  // Represents the version of a GSC firmware target.
+  message GscTestEffortVersion {
+    // Required. The major version of the GSC firmware.
+    int32 major_version = 1;
+
+    // Required. The minor version of the GSC firmware.
+    int32 minor_version = 2;
+
+    // Required. The generation of the GSC firmware.
+    int32 generation = 3;
+  }
+
+  // NEXT_TAG = 5
+  // Specifies the firmware target and its version.
+  message FirmwareTarget {
+    oneof fw {
+      // Embedded Controller firmware version.
+      FirmwareTestEffortVersion ec = 1;
+
+      // Application Processor firmware version.
+      FirmwareTestEffortVersion ap = 2;
+
+      // Google Security Chip firmware version.
+      GscTestEffortVersion gsc = 3;
+
+      // Fingerprint Microcontroller firmware version.
+      FirmwareTestEffortVersion fpmcu = 4;
+    }
+  }
+
+  // NEXT_TAG = 3
+  // The type of firmware (read-only or read-write).
+  enum FirmwareType {
+    FIRMWARE_TYPE_UNSPECIFIED = 0;
+    FIRMWARE_TYPE_READ_ONLY = 1;
+    FIRMWARE_TYPE_READ_WRITE = 2;
+  }
+
+  // NEXT_TAG = 3
+  // Configuration for a firmware target.
+  message FirmwareConfig {
+    // Required. The firmware target and version.
+    FirmwareTarget firmware_target = 1;
+
+    // Required. The firmware types (e.g. FIRMWARE_TYPE_READ_ONLY, FIRMWARE_TYPE_READ_WRITE).
+    repeated FirmwareType firmware_types = 2;
+  }
+
   // Output only. The ID of the test effort.
   string id = 1;
 
@@ -1444,6 +1506,9 @@
 
   // Option to make boot tests optional.
   bool skip_boot_prerequisite = 12;
+
+  // Optional. Firmware configurations to test against.
+  repeated FirmwareConfig firmware_configs = 13;
 }
 
 // NEXT_TAG = 2
diff --git a/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.html b/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.html
index 62561e2..ae810b4 100644
--- a/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.html
+++ b/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.html
@@ -209,6 +209,114 @@
           (isLoadingChanged)="onLoadingChanged($event)"
         ></app-android-build-picker>
 
+        <div class="my-2">
+          <mat-slide-toggle
+            (change)="onFirmwareVersionEnabledChanged($event.checked)"
+            [checked]="isFirmwareVersionEnabledSignal()"
+            [disabled]="isScheduleRunLoading().show"
+          >
+            Add Firmware Versions
+          </mat-slide-toggle>
+        </div>
+
+        <div
+          *ngIf="isFirmwareVersionEnabledSignal()"
+          class="flex flex-col gap-3 my-2 p-4 border border-gray-200 rounded"
+        >
+          <div
+            *ngFor="
+              let row of firmwareRowsSignal();
+              let i = index;
+              trackBy: trackByFirmwareRowIndex
+            "
+            class="flex flex-col gap-1"
+          >
+            <div class="flex flex-row items-end gap-3 w-full">
+              <div class="w-32">
+                <label class="block text-xs text-blue-500 mb-1">Target:</label>
+                <mat-select
+                  [value]="row.target"
+                  (selectionChange)="onFirmwareTargetChanged(i, $event.value)"
+                  [disabled]="isScheduleRunLoading().show"
+                  class="border-b border-gray-400 pb-1 text-sm"
+                >
+                  <mat-option
+                    *ngFor="let targetOpt of firmwareTargetOptions"
+                    [value]="targetOpt.value"
+                  >
+                    {{ targetOpt.label }}
+                  </mat-option>
+                </mat-select>
+              </div>
+
+              <div class="w-24">
+                <label class="block text-xs text-blue-500 mb-1">Type:</label>
+                <mat-select
+                  [value]="row.type"
+                  (selectionChange)="onFirmwareTypeChanged(i, $event.value)"
+                  [disabled]="isScheduleRunLoading().show"
+                  class="border-b border-gray-400 pb-1 text-sm"
+                >
+                  <mat-option
+                    *ngFor="let typeOpt of firmwareTypeOptions"
+                    [value]="typeOpt.value"
+                  >
+                    {{ typeOpt.label }}
+                  </mat-option>
+                </mat-select>
+              </div>
+
+              <div class="flex-1">
+                <label class="block text-xs text-blue-500 mb-1">Version:</label>
+                <input
+                  type="text"
+                  placeholder="e.g. 1.2.3"
+                  [value]="row.version"
+                  (input)="onFirmwareVersionInputChanged(i, $event)"
+                  [disabled]="isScheduleRunLoading().show"
+                  class="w-full block border-b border-gray-400 pb-1 focus:border-blue-500 bg-transparent outline-none text-sm font-light font-sans disabled:text-gray-300"
+                  [class.border-red-500]="firmwareRowErrors()[i] !== ''"
+                />
+              </div>
+
+              <button
+                type="button"
+                class="flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100 text-gray-600 disabled:text-gray-300 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
+                (click)="onRemoveFirmwareRowClicked(i)"
+                [disabled]="
+                  firmwareRowsSignal().length <= 1 ||
+                  isScheduleRunLoading().show
+                "
+                title="Remove firmware version"
+              >
+                <span class="material-icon">remove</span>
+              </button>
+            </div>
+
+            <p
+              *ngIf="firmwareRowErrors()[i] !== ''"
+              class="text-red-500 text-xs m-0"
+            >
+              {{ firmwareRowErrors()[i] }}
+            </p>
+          </div>
+
+          <div class="flex justify-start mt-1">
+            <button
+              type="button"
+              class="flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100 text-blue-500 disabled:text-gray-300 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
+              (click)="onAddFirmwareRowClicked()"
+              [disabled]="
+                firmwareRowsSignal().length >= MAX_FIRMWARE_ROWS ||
+                isScheduleRunLoading().show
+              "
+              title="Add firmware version"
+            >
+              <span class="material-icon">add</span>
+            </button>
+          </div>
+        </div>
+
         <app-basic-selector
           (select)="onPropsChanged('pool', $event)"
           [options]="poolOptions()"
diff --git a/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.spec.ts b/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.spec.ts
index a990841..fbab810 100644
--- a/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.spec.ts
+++ b/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.spec.ts
@@ -1,14 +1,56 @@
 import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {RouterTestingModule} from '@angular/router/testing';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {of} from 'rxjs';
+import {AndroidService} from 'app/services/android.service';
+import {SatlabRpcService} from 'app/services/satlab-rpc.service';
+import {NotificationService} from 'app/services/notification.service';
+import {
+  CreateTestEffortResponse,
+  ListTestEffortsResponse,
+} from 'app/services/satlabrpc_pb';
 
 import {AutoQualComponent} from './auto-qual.component';
 
 describe('AutoQualComponent', () => {
   let component: AutoQualComponent;
   let fixture: ComponentFixture<AutoQualComponent>;
+  let androidServiceSpy: jasmine.SpyObj<AndroidService>;
 
   beforeEach(() => {
+    androidServiceSpy = jasmine.createSpyObj('AndroidService', [
+      'listTestEfforts',
+      'createTestEffort',
+      'cancelTestEffort',
+      'validateBuild',
+      'listBranches',
+      'listTargets',
+      'listBuilds',
+    ]);
+    androidServiceSpy.listTestEfforts.and.returnValue(
+      of(new ListTestEffortsResponse())
+    );
+    androidServiceSpy.createTestEffort.and.returnValue(
+      of(new CreateTestEffortResponse().setId('effort-1'))
+    );
+
+    const rpcServiceSpy = jasmine.createSpyObj('SatlabRpcService', [
+      'listEnrolledDUTs',
+    ]);
+    rpcServiceSpy.listEnrolledDUTs.and.returnValue(Promise.resolve([]));
+
+    const notificationSpy = jasmine.createSpyObj('NotificationService', [
+      'info',
+      'error',
+    ]);
+
     TestBed.configureTestingModule({
-      imports: [AutoQualComponent],
+      imports: [AutoQualComponent, RouterTestingModule, NoopAnimationsModule],
+      providers: [
+        {provide: AndroidService, useValue: androidServiceSpy},
+        {provide: SatlabRpcService, useValue: rpcServiceSpy},
+        {provide: NotificationService, useValue: notificationSpy},
+      ],
     });
     fixture = TestBed.createComponent(AutoQualComponent);
     component = fixture.componentInstance;
@@ -18,4 +60,110 @@
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  it('should enforce min 1 and max 8 firmware rows', () => {
+    const comp = component as unknown as {
+      firmwareRowsSignal: () => {target: string; type: string; version: string}[];
+      onAddFirmwareRowClicked: () => void;
+      onRemoveFirmwareRowClicked: (index: number) => void;
+    };
+
+    expect(comp.firmwareRowsSignal().length).toBe(1);
+
+    // Cannot delete the last remaining row
+    comp.onRemoveFirmwareRowClicked(0);
+    expect(comp.firmwareRowsSignal().length).toBe(1);
+
+    // Add rows up to the max of 8
+    for (let i = 0; i < 10; i++) {
+      comp.onAddFirmwareRowClicked();
+    }
+    expect(comp.firmwareRowsSignal().length).toBe(8);
+
+    // Can delete when > 1 row
+    comp.onRemoveFirmwareRowClicked(7);
+    expect(comp.firmwareRowsSignal().length).toBe(7);
+  });
+
+  it('should validate firmware version format and duplicate target/type pairs', () => {
+    const comp = component as unknown as {
+      isFirmwareVersionEnabledSignal: {set: (v: boolean) => void};
+      firmwareRowsSignal: {
+        set: (v: {target: string; type: string; version: string}[]) => void;
+      };
+      firmwareRowErrors: () => string[];
+      isFirmwareValid: () => boolean;
+    };
+
+    comp.isFirmwareVersionEnabledSignal.set(true);
+
+    // Blank version is valid
+    comp.firmwareRowsSignal.set([{target: 'ap', type: 'ro', version: ''}]);
+    expect(comp.isFirmwareValid()).toBeTrue();
+
+    // Invalid version format
+    comp.firmwareRowsSignal.set([{target: 'ap', type: 'ro', version: '1.2'}]);
+    expect(comp.isFirmwareValid()).toBeFalse();
+    expect(comp.firmwareRowErrors()[0]).toContain('major.minor.patch');
+
+    // Valid version format
+    comp.firmwareRowsSignal.set([
+      {target: 'ap', type: 'ro', version: '16759.22.0'},
+    ]);
+    expect(comp.isFirmwareValid()).toBeTrue();
+
+    // Duplicate (target, type) combination
+    comp.firmwareRowsSignal.set([
+      {target: 'ap', type: 'ro', version: '16759.22.0'},
+      {target: 'ap', type: 'ro', version: '16759.23.0'},
+    ]);
+    expect(comp.isFirmwareValid()).toBeFalse();
+    expect(comp.firmwareRowErrors()[0]).toContain('Duplicate');
+  });
+
+  it('should forward non-empty firmware rows to createTestEffort', () => {
+    const comp = component as unknown as {
+      boardSignal: {set: (v: string) => void};
+      modelSignal: {set: (v: string) => void};
+      branchSignal: {set: (v: string) => void};
+      boardTargetSignal: {set: (v: string) => void};
+      testTargetSignal: {set: (v: string) => void};
+      poolSignal: {set: (v: string) => void};
+      buildSignal: {set: (v: string) => void};
+      validBuildSignal: {set: (v: string) => void};
+      testplanSignal: {set: (v: string) => void};
+      isFirmwareVersionEnabledSignal: {set: (v: boolean) => void};
+      firmwareRowsSignal: {
+        set: (v: {target: string; type: string; version: string}[]) => void;
+      };
+      onScheduleClicked: () => void;
+    };
+
+    comp.boardSignal.set('tanjiro');
+    comp.modelSignal.set('sapphire');
+    comp.branchSignal.set('arsp-26Q3-desktop-release');
+    comp.boardTargetSignal.set('sapphire-userdebug');
+    comp.testTargetSignal.set('test_suites_x86_64');
+    comp.poolSignal.set('satlab-pool');
+    comp.buildSignal.set('12345');
+    comp.validBuildSignal.set('12345');
+    comp.testplanSignal.set('dts/avs/firmware');
+    comp.isFirmwareVersionEnabledSignal.set(true);
+    comp.firmwareRowsSignal.set([
+      {target: 'ap', type: 'ro', version: '16759.22.0'},
+      {target: 'ap', type: 'rw', version: '16759.22.0'},
+      {target: 'ec', type: 'ro', version: ''},
+    ]);
+
+    comp.onScheduleClicked();
+
+    expect(androidServiceSpy.createTestEffort).toHaveBeenCalledWith(
+      jasmine.objectContaining({
+        firmwareConfigs: [
+          {target: 'ap', type: 'ro', major: 16759, minor: 22, patch: 0},
+          {target: 'ap', type: 'rw', major: 16759, minor: 22, patch: 0},
+        ],
+      })
+    );
+  });
 });
diff --git a/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.ts b/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.ts
index 09cba3a..b0e1e0e 100644
--- a/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.ts
+++ b/src/satlab-ui/src/app/run_suite/android/auto-qual/auto-qual.component.ts
@@ -19,7 +19,12 @@
 import {AutocompleteComponent} from 'app/run_suite/common/autocomplete/autocomplete.component';
 import {BasicSelectorComponent} from 'app/run_suite/common/basic-selector/basic-selector.component';
 import {LoadingComponent} from 'app/run_suite/common/loading/loading.component';
-import {AndroidService} from 'app/services/android.service';
+import {
+  AndroidService,
+  FirmwareConfigParam,
+  FirmwareReadabilityType,
+  FirmwareTargetType,
+} from 'app/services/android.service';
 import {NotificationService} from 'app/services/notification.service';
 import {SatlabRpcService} from 'app/services/satlab-rpc.service';
 import {TestEffort} from 'app/services/satlabrpc_pb';
@@ -27,11 +32,18 @@
 import {resetSignals, wrapperLoading} from 'app/utils/operators';
 import {from} from 'rxjs';
 import {ActivatedRoute, Router, RouterModule} from '@angular/router';
+import {MatSelectModule} from '@angular/material/select';
 import {MatSlideToggleModule} from '@angular/material/slide-toggle';
 import {ALBuildBasic, ICustomSettings} from 'app/models/run_suite_fields';
 import {AdvancedSettingsComponent} from 'app/run_suite/common/advanced-settings/advanced-settings.component';
 import {AndroidBuildPickerComponent} from '../../android/common/android-build-picker/android-build-picker.component';
 
+export interface FirmwareRow {
+  target: FirmwareTargetType;
+  type: FirmwareReadabilityType;
+  version: string;
+}
+
 export enum Status {
   SCHEDULED = 'Scheduled',
   IN_PROGRESS = 'In progress',
@@ -51,6 +63,7 @@
     BasicSelectorComponent,
     AutocompleteComponent,
     RouterModule,
+    MatSelectModule,
     MatSlideToggleModule,
     AndroidBuildPickerComponent,
     AdvancedSettingsComponent,
@@ -197,6 +210,66 @@
   protected testTargetSignal = signal<string>('');
   protected testValidBuildSignal = signal<string>('');
 
+  protected readonly firmwareTargetOptions: {
+    value: FirmwareTargetType;
+    label: string;
+  }[] = [
+    {value: 'ap', label: 'AP'},
+    {value: 'ec', label: 'EC'},
+    {value: 'gsc', label: 'GSC'},
+    {value: 'fpmcu', label: 'FPMCU'},
+  ];
+  protected readonly firmwareTypeOptions: {
+    value: FirmwareReadabilityType;
+    label: string;
+  }[] = [
+    {value: 'ro', label: 'RO'},
+    {value: 'rw', label: 'RW'},
+  ];
+  protected readonly MAX_FIRMWARE_ROWS =
+    this.firmwareTargetOptions.length * this.firmwareTypeOptions.length;
+  private readonly FIRMWARE_VERSION_REGEX = /^\d+\.\d+\.\d+$/;
+  private readonly MAX_INT32 = 2147483647;
+
+  protected isFirmwareVersionEnabledSignal = signal<boolean>(false);
+  protected firmwareRowsSignal = signal<FirmwareRow[]>([
+    {target: 'ap', type: 'ro', version: ''},
+  ]);
+
+  protected firmwareRowErrors = computed<string[]>(() => {
+    const rows = this.firmwareRowsSignal();
+    const pairCounts = new Map<string, number>();
+    for (const row of rows) {
+      const key = `${row.target}:${row.type}`;
+      pairCounts.set(key, (pairCounts.get(key) || 0) + 1);
+    }
+
+    return rows.map(row => {
+      const key = `${row.target}:${row.type}`;
+      if ((pairCounts.get(key) || 0) > 1) {
+        return 'Duplicate target and type combination.';
+      }
+      const trimmed = row.version.trim();
+      if (trimmed !== '') {
+        if (!this.FIRMWARE_VERSION_REGEX.test(trimmed)) {
+          return 'Format must be major.minor.patch (e.g. 1.2.3).';
+        }
+        const parts = trimmed.split('.').map(Number);
+        if (parts.some(p => p > this.MAX_INT32)) {
+          return 'Version numbers are out of range.';
+        }
+      }
+      return '';
+    });
+  });
+
+  protected isFirmwareValid = computed<boolean>(() => {
+    if (!this.isFirmwareVersionEnabledSignal()) {
+      return true;
+    }
+    return this.firmwareRowErrors().every(err => err === '');
+  });
+
   protected notAvailableMsg = signal<string>('');
 
   protected isRunnable = computed(() => {
@@ -229,6 +302,7 @@
       validBuild !== '' &&
       testplan !== '' &&
       crossBranchValid &&
+      this.isFirmwareValid() &&
       !errMsg
     );
   });
@@ -463,6 +537,78 @@
     this.isCrossBranchSignal.set(value);
   }
 
+  protected onFirmwareVersionEnabledChanged(value: boolean) {
+    this.isFirmwareVersionEnabledSignal.set(value);
+    if (!value) {
+      this.firmwareRowsSignal.set([{target: 'ap', type: 'ro', version: ''}]);
+    }
+  }
+
+  protected onAddFirmwareRowClicked() {
+    const rows = this.firmwareRowsSignal();
+    if (rows.length >= this.MAX_FIRMWARE_ROWS) {
+      return;
+    }
+
+    const usedPairs = new Set(rows.map(r => `${r.target}:${r.type}`));
+    let nextTarget: FirmwareTargetType = 'ap';
+    let nextType: FirmwareReadabilityType = 'ro';
+    let found = false;
+
+    for (const targetOpt of this.firmwareTargetOptions) {
+      for (const typeOpt of this.firmwareTypeOptions) {
+        if (!usedPairs.has(`${targetOpt.value}:${typeOpt.value}`)) {
+          nextTarget = targetOpt.value;
+          nextType = typeOpt.value;
+          found = true;
+          break;
+        }
+      }
+      if (found) {
+        break;
+      }
+    }
+
+    this.firmwareRowsSignal.set([
+      ...rows,
+      {target: nextTarget, type: nextType, version: ''},
+    ]);
+  }
+
+  protected onRemoveFirmwareRowClicked(index: number) {
+    const rows = this.firmwareRowsSignal();
+    if (rows.length <= 1) {
+      return;
+    }
+    this.firmwareRowsSignal.set(rows.filter((_, i) => i !== index));
+  }
+
+  protected onFirmwareTargetChanged(index: number, target: FirmwareTargetType) {
+    this.firmwareRowsSignal.update(rows =>
+      rows.map((row, i) => (i === index ? {...row, target} : row))
+    );
+  }
+
+  protected onFirmwareTypeChanged(
+    index: number,
+    type: FirmwareReadabilityType
+  ) {
+    this.firmwareRowsSignal.update(rows =>
+      rows.map((row, i) => (i === index ? {...row, type} : row))
+    );
+  }
+
+  protected onFirmwareVersionInputChanged(index: number, event: Event) {
+    const version = (event.target as HTMLInputElement).value;
+    this.firmwareRowsSignal.update(rows =>
+      rows.map((row, i) => (i === index ? {...row, version} : row))
+    );
+  }
+
+  protected trackByFirmwareRowIndex(index: number) {
+    return index;
+  }
+
   protected onAdvancedSettingsChanged(settings: ICustomSettings) {
     this.customSettings = settings;
     this.skipBootPrerequisiteSignal.set(!!settings.skipBootPrerequisite);
@@ -481,6 +627,24 @@
   protected onScheduleClicked() {
     const testplan = this.testplanSignal();
     const isCrossBranch = this.isCrossBranchSignal();
+    const firmwareConfigs: FirmwareConfigParam[] =
+      this.isFirmwareVersionEnabledSignal()
+        ? this.firmwareRowsSignal()
+            .filter(row => row.version.trim() !== '')
+            .map(row => {
+              const [major, minor, patch] = row.version
+                .trim()
+                .split('.')
+                .map(Number);
+              return {
+                target: row.target,
+                type: row.type,
+                major,
+                minor,
+                patch,
+              };
+            })
+        : [];
 
     this.__createTestEffort({
       board: this.boardSignal(),
@@ -496,6 +660,7 @@
         ? this.testValidBuildSignal()
         : this.validBuildSignal(),
       skipBootPrerequisite: this.skipBootPrerequisiteSignal(),
+      firmwareConfigs: firmwareConfigs.length > 0 ? firmwareConfigs : undefined,
     });
   }
 
@@ -585,6 +750,7 @@
     testTarget?: string;
     testBuild?: string;
     skipBootPrerequisite?: boolean;
+    firmwareConfigs?: FirmwareConfigParam[];
   }) {
     wrapperLoading(
       from(
@@ -602,6 +768,7 @@
           testTarget: p.testTarget,
           testBuild: p.testBuild,
           skipBootPrerequisite: p.skipBootPrerequisite,
+          firmwareConfigs: p.firmwareConfigs,
         })
       ),
       this.isScheduleRunLoading,
diff --git a/src/satlab-ui/src/app/services/android.service.spec.ts b/src/satlab-ui/src/app/services/android.service.spec.ts
index 9473a2d..c99d1d9 100644
--- a/src/satlab-ui/src/app/services/android.service.spec.ts
+++ b/src/satlab-ui/src/app/services/android.service.spec.ts
@@ -1,5 +1,7 @@
 import {AndroidService} from './android.service';
+import {CreateTestEffortRequest, CreateTestEffortResponse, TestEffort} from './satlabrpc_pb';
 import {TestBed} from '@angular/core/testing';
+import {firstValueFrom} from 'rxjs';
 
 describe('AndroidService', () => {
   let service: AndroidService;
@@ -12,4 +14,66 @@
   it('should be created', () => {
     expect(service).toBeTruthy();
   });
+
+  it('should populate firmwareConfigs in CreateTestEffortRequest', async () => {
+    let capturedRequest: CreateTestEffortRequest | undefined;
+    spyOn(
+      (service as unknown as {client: {createTestEffort: jasmine.Spy}}).client,
+      'createTestEffort'
+    ).and.callFake((req: CreateTestEffortRequest) => {
+      capturedRequest = req;
+      return Promise.resolve(new CreateTestEffortResponse().setId('effort-1'));
+    });
+
+    await firstValueFrom(
+      service.createTestEffort({
+        board: 'fatcat',
+        model: 'ruby',
+        branch: 'arsp-main',
+        target: 'ruby-userdebug',
+        build: '123456',
+        testplan: 'dts/avs/firmware',
+        pools: [{label: 'pool-1', type: 1}],
+        firmwareConfigs: [
+          {target: 'ap', type: 'ro', major: 16759, minor: 22, patch: 0},
+          {target: 'ec', type: 'rw', major: 16765, minor: 2, patch: 21},
+          {target: 'gsc', type: 'ro', major: 1, minor: 2, patch: 3},
+          {target: 'fpmcu', type: 'rw', major: 4, minor: 5, patch: 6},
+        ],
+      })
+    );
+
+    expect(capturedRequest).toBeDefined();
+    const effort = capturedRequest!.getEffort()!;
+    const fwConfigs = effort.getFirmwareConfigsList();
+    expect(fwConfigs.length).toBe(4);
+
+    expect(fwConfigs[0].getFirmwareTarget()?.getAp()?.getMajorVersion()).toBe(16759);
+    expect(fwConfigs[0].getFirmwareTarget()?.getAp()?.getMinorVersion()).toBe(22);
+    expect(fwConfigs[0].getFirmwareTarget()?.getAp()?.getPatchNumber()).toBe(0);
+    expect(fwConfigs[0].getFirmwareTypesList()).toEqual([
+      TestEffort.FirmwareType.FIRMWARE_TYPE_READ_ONLY,
+    ]);
+
+    expect(fwConfigs[1].getFirmwareTarget()?.getEc()?.getMajorVersion()).toBe(16765);
+    expect(fwConfigs[1].getFirmwareTarget()?.getEc()?.getMinorVersion()).toBe(2);
+    expect(fwConfigs[1].getFirmwareTarget()?.getEc()?.getPatchNumber()).toBe(21);
+    expect(fwConfigs[1].getFirmwareTypesList()).toEqual([
+      TestEffort.FirmwareType.FIRMWARE_TYPE_READ_WRITE,
+    ]);
+
+    expect(fwConfigs[2].getFirmwareTarget()?.getGsc()?.getMajorVersion()).toBe(1);
+    expect(fwConfigs[2].getFirmwareTarget()?.getGsc()?.getMinorVersion()).toBe(2);
+    expect(fwConfigs[2].getFirmwareTarget()?.getGsc()?.getGeneration()).toBe(3);
+    expect(fwConfigs[2].getFirmwareTypesList()).toEqual([
+      TestEffort.FirmwareType.FIRMWARE_TYPE_READ_ONLY,
+    ]);
+
+    expect(fwConfigs[3].getFirmwareTarget()?.getFpmcu()?.getMajorVersion()).toBe(4);
+    expect(fwConfigs[3].getFirmwareTarget()?.getFpmcu()?.getMinorVersion()).toBe(5);
+    expect(fwConfigs[3].getFirmwareTarget()?.getFpmcu()?.getPatchNumber()).toBe(6);
+    expect(fwConfigs[3].getFirmwareTypesList()).toEqual([
+      TestEffort.FirmwareType.FIRMWARE_TYPE_READ_WRITE,
+    ]);
+  });
 });
diff --git a/src/satlab-ui/src/app/services/android.service.ts b/src/satlab-ui/src/app/services/android.service.ts
index c7da164..23d4089 100644
--- a/src/satlab-ui/src/app/services/android.service.ts
+++ b/src/satlab-ui/src/app/services/android.service.ts
@@ -31,6 +31,17 @@
 
 export type XtsType = (typeof XTS_TYPES)[number];
 
+export type FirmwareTargetType = 'ap' | 'ec' | 'gsc' | 'fpmcu';
+export type FirmwareReadabilityType = 'ro' | 'rw';
+
+export interface FirmwareConfigParam {
+  target: FirmwareTargetType;
+  type: FirmwareReadabilityType;
+  major: number;
+  minor: number;
+  patch: number;
+}
+
 export interface XtsAndroidBuildPin {
   testBuild: string;
   testBranch: string;
@@ -353,6 +364,7 @@
     testTarget?: string;
     testBuild?: string;
     skipBootPrerequisite?: boolean;
+    firmwareConfigs?: FirmwareConfigParam[];
   }) {
     const poolLists = params.pools.map(p => {
       return new TestEffort.Pool().setLabel(p.label).setType(p.type);
@@ -381,6 +393,46 @@
       testEffort.setAndroidTestTarget(androidTestTarget);
     }
 
+    if (params.firmwareConfigs && params.firmwareConfigs.length > 0) {
+      const fwConfigs = params.firmwareConfigs.map(cfg => {
+        const fwTarget = new TestEffort.FirmwareTarget();
+        if (cfg.target === 'gsc') {
+          const gscVer = new TestEffort.GscTestEffortVersion()
+            .setMajorVersion(cfg.major)
+            .setMinorVersion(cfg.minor)
+            .setGeneration(cfg.patch);
+          fwTarget.setGsc(gscVer);
+        } else {
+          const stdVer = new TestEffort.FirmwareTestEffortVersion()
+            .setMajorVersion(cfg.major)
+            .setMinorVersion(cfg.minor)
+            .setPatchNumber(cfg.patch);
+          switch (cfg.target) {
+            case 'ap':
+              fwTarget.setAp(stdVer);
+              break;
+            case 'ec':
+              fwTarget.setEc(stdVer);
+              break;
+            case 'fpmcu':
+              fwTarget.setFpmcu(stdVer);
+              break;
+          }
+        }
+
+        const fwType =
+          cfg.type === 'ro'
+            ? TestEffort.FirmwareType.FIRMWARE_TYPE_READ_ONLY
+            : TestEffort.FirmwareType.FIRMWARE_TYPE_READ_WRITE;
+
+        return new TestEffort.FirmwareConfig()
+          .setFirmwareTarget(fwTarget)
+          .setFirmwareTypesList([fwType]);
+      });
+
+      testEffort.setFirmwareConfigsList(fwConfigs);
+    }
+
     const req = new CreateTestEffortRequest().setEffort(testEffort);
     return from(this.client.createTestEffort(req, {}));
   }