
Security News
Suno Breached via Shai-Hulud Worm, Leaked Code Exposes AI Music Scraping
A Shai-Hulud infection exposed Suno's source code, which shows the AI music startup stream-ripped tracks to train its models.
angular-formsbuilder-gen
Advanced tools
Accelerate Angular Development with Auto Reactive Forms Builder Generator Based On Open API
Generate strongly-typed Angular Reactive Forms (and optional Signals-based forms) directly from your OpenAPI / Swagger schema. This package works together with ng-openapi-gen to turn backend API contracts into ergonomic form builder services with rich validation and UI helpers.
FormGroup-based builders for each schema model.a => a.email) for getControl, setFieldValue, watchField.submit(saveFn) with automatic isSubmitting flag management.persistTo / restoreFrom for saving drafts to localStorage/sessionStorage.markAllTouched(), disableAll(), enableAll(), clone() out of the box.CanDeactivate guard prompting users before leaving dirty forms.provideAllFormBuilders() / provideFormBuilders(...) functions eliminate manual provider lists.FormWizard<T> with fluent .setStep() API, IntelliSense field selection, step validation, and onStepChange observable.ValidationManager, typed error keys, placeholders, and i18n-ready messages.<afb-validation-errors> component, and afbAutoErrors directive with form.showValidationErrors().is-invalid and invalid-feedback by default, fully customizable via config APIs.--customize.swagger.json configuration to generate both API clients and form builders.To install "angular-formsbuilder-gen" globally or within your project, run the following commands:
npm install -g ng-openapi-gen
npm install -g angular-formsbuilder-gen
Initialize a configuration file (optional but recommended):
ng-frmGenerator init
This creates a swagger.json in the current folder with sensible defaults. You can also use a custom name:
ng-frmGenerator init my-swagger.json
Generate Angular API services and models using ng-openapi-gen:
ng-openapi-gen -c swagger.json
Generate reactive form builder classes from the same config:
ng-frmGenerator swagger.json
If your config file is named swagger.json in the current folder, you can also just run:
ng-frmGenerator
Use the generated form builder in your Angular component:
@Component({
selector: 'app-user-form',
templateUrl: './user-form.component.html',
providers: [CustomerDtoFormBuilder.provider()]
})
export class UserFormComponent {
form = this.customerFormBuilder.buildForm();
constructor(private customerFormBuilder: CustomerDtoFormBuilder) {}
submit() {
this.form.showValidationErrors();
if (this.form.invalid) {
return;
}
const model = this.form.value;
// send model to API
}
}
<form [formGroup]="form" afbAutoErrors (ngSubmit)="submit()">
<!-- your controls here -->
<button type="submit" class="btn btn-primary">Save</button>
</form>
Optionally enable Signals-based templates by setting "useSignalFormTemplates": true in swagger.json.
You can manually create a configuration file named swagger.json in the root of your Angular app with the following content, or let the CLI scaffold it for you (see below):
{
"$schema": "node_modules/ng-openapi-gen/ng-openapi-gen-schema.json",
"input": "https://localhost:44325/swagger/v1/swagger.json",
"output": "./src/app/api",
"ignoreUnusedModels": false,
"modelsPath": "./../api/models",
"formsOutput": "/src/app/forms",
"schemeFile": "E://swagger.json",
"useEnumValuesAsString": false,
"useSignalFormTemplates": false,
"generateFormsHelpers": true,
"generateCustomValidators": true,
"generateValidationManager": true,
"generateShowForErrorDirective": true,
"generateIFormBuilder": true,
"generateDateHelper": true,
"generateEnumHelper": true,
"customFormTemplatePath": "",
"customSignalFormTemplatePath": "",
"generateValidationUiHelpers": true,
"cleanupUnusedFiles": true,
"includeModels": [],
"excludeModels": [],
"generateValidationMessagesJson": true,
"disableTlsVerification": false,
"generateFormWizard": false
}
Note: This file is also used by the ng-openapi-gen tool.
Instead of creating swagger.json by hand, you can ask the generator to create an initial file for you:
ng-frmGenerator init
This will create swagger.json in the current folder using the same defaults shown above.
You can also specify a custom file name:
ng-frmGenerator init my-config.json
If the target file already exists, the command will print a message and exit without overwriting it.
Our tool specifically uses the properties below:
true, generated enum fields will use string values instead of enum member references.true, generates form builder classes that also expose Angular Signals-based state (formValueSignal, isValidSignal) on top of reactive forms. Intended for Angular 17+.true): Control whether FormsHelpers.ts is generated.true): Control whether CustomeValidators.ts is generated.true): Control whether ValidationManager.ts is generated.true): Control whether ShowForErrorDirective.ts is generated.true): Control whether IFormBuilder.ts is generated.true): Control whether DateHelper.ts is generated.true): Control whether EnumHelper.ts is generated.getTemplate(key, services) function (or formBuilderTemplate.getTemplate). When provided and useSignalFormTemplates is false, this function is used to generate the form builder class instead of the built-in template.customFormTemplatePath, but used when useSignalFormTemplates is true.true): Control whether ValidationUiHelpers.ts (pipes + <afb-validation-errors> component and afbAutoErrors directive) is generated.true): When true, remove .ts files under formsOutput that no longer correspond to current schema models or enabled helpers.true): When true, generate validation-messages.en.json containing default validation messages for easy integration with i18n tools.false): When true, disables TLS certificate verification for HTTPS schema URLs (useful for self-signed certs in development).false): When true, generates FormWizard.ts — a generic multi-step wizard utility with fluent API.First, generate services and models using ng-openapi-gen :
ng-openapi-gen -c swagger.json
Ensure that files are generated in the "output" path defined in swagger.json.
To generate Angular models' FormBuilder classes, execute the following command:
ng-frmGenerator swagger.json
or
ng-frmGenerator
only because default filename for configuration is "swagger.json"
Here is an example of a generated FormBuilder class for a simple user information form:
import { Injectable } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormControl, FormArray, FormGroup, FormBuilder, AbstractControl } from '@angular/forms';
import { IFormBuilder } from './IFormBuilder';
import { CustomerDto, UserAddressDto } from './../api/models';
import { UserAddressDtoFormBuilder } from './UserAddressDto';
import { oneOfValidator, guidValidator } from './CustomeValidators';
@Injectable({ providedIn: 'root' })
export class CustomerDtoFormBuilder implements IFormBuilder<CustomerDto> {
DatePipe: DatePipe = null as any;
DateFormat: string = 'yyyy-MM-dd';
form: FormGroup = null as any;
constructor(private fb: FormBuilder
, private UserAddressDtoFormBuilderSrvc: UserAddressDtoFormBuilder
) {
this.DatePipe = new DatePipe('en-US');
}
updateCulture(culture: string = 'en-US') {
this.DatePipe = new DatePipe(culture);
}
resetForm() {
this.form.reset();
}
buildForm(model: CustomerDto | null = null) {
this.form = this.fb.group({
userName: [ '' , Validators.compose([ Validators.required, Validators.minLength(1) ]) ],
password: [ '' , Validators.compose([ Validators.required ]) ],
addresses: [ this.UserAddressDtoFormBuilderSrvc.buildForm() ],
});
if (model != null) {
this.form.patchValue({ ...model });
}
return this.form;
}
get userNameCtrl(): FormControl {
return this.form.get('userName') as FormControl;
}
get userNameValueChanges$() {
return this.userNameCtrl?.valueChanges;
}
get passwordCtrl(): FormControl {
return this.form.get('password') as FormControl;
}
get passwordValueChanges$() {
return this.passwordCtrl?.valueChanges;
}
addressesArray(): FormArray {
return this.form.controls['addresses'] as FormArray;
}
addressesControls(): AbstractControl<any, any>[] {
return this.addressesArray().controls;
}
deleteAddressesByIndex(index: number): void {
this.addressesArray().removeAt(index);
}
addNewAddresses(model: UserAddressDtoFormBuilder | null = null): FormGroup<any> {
let frm = this.UserAddressDtoFormBuilderSrvc.buildForm(model);
this.addressesArray().push(frm);
return frm;
}
addNewaddresses(model: UserAddressDto | null = null): FormGroup<any> {
let frm = this.UserAddressDtoFormBuilderSrvc.buildForm(model);
this.addressesArray().push(frm);
return frm;
}
}
---
## Validation Ecosystem (Error Handling & Messages)
This generator ships a rich validation ecosystem to help you standardize error handling across your Angular app.
### Centralized ValidationManager
Generated file: `ValidationManager.ts`
- Exposes **typed error keys** via `ErrorTypes` / `ErrorKey` / `ErrorCode`.
- Provides default English messages with placeholders (e.g. `{requiredLength}`, `{min}`, `{max}`).
- Core APIs:
```ts
ValidationManager.getErrorMessage(control: FormControl): string;
ValidationManager.getMessages(control: AbstractControl): ValidationMessageVM[];
ValidationManager.errors$(control: AbstractControl): Observable<ValidationMessageVM[]>;
You can plug in your own localization/branding using the message resolver hook:
ValidationManager.setMessageResolver(({ key, code, error, defaultTemplate, control }) => {
// key: 'Required' | 'MinLength' | ...
// code: 'required' | 'minlength' | ...
// error: Angular error object (e.g. { requiredLength, actualLength })
// defaultTemplate: e.g. 'Minimum length is {requiredLength} characters'
// Example: integrate with your i18n service
const translationKey = `validation.${key}`;
return myTranslate(translationKey, error) ?? defaultTemplate;
});
You can define a global error template that will be auto-filled with the primary error message and control key.
ValidationManager.defineGlobalTemplate(
(control, element, messages) => `
<div class="invalid-feedback" for="ctrlKey">
ErrorPlaceHolder
</div>
`,
{
message: 'ErrorPlaceHolder', // will be replaced with the first error message
controlKey: 'ctrlKey' // will be replaced with the control name
}
);
At runtime you can build the resolved HTML for a specific control/element:
const html = ValidationManager.buildGlobalTemplate({
control: myControl,
element: myInputElement,
controlKey: 'userName'
});
Generated file: ValidationUiHelpers.ts (enabled when generateValidationUiHelpers: true). It contains:
ValidationErrorPipe – returns the first error message for a control.
<div *ngIf="userNameCtrl | validationError as err">
{{ err }}
</div>
ValidationErrorsPipe – returns all messages (ValidationMessageVM[]).
<ul *ngIf="userNameCtrl | validationErrors as errs">
<li *ngFor="let e of errs">{{ e.message }}</li>
</ul>
ValidationErrorsComponent – reusable error list component:
<afb-validation-errors [control]="userNameCtrl" [showAll]="false"></afb-validation-errors>
AfbAutoErrorsDirective – attaches to a form and automatically appends the global error template next to invalid controls and also extends the FormGroup with a helper method:
<!-- auto show errors on submit -->
<form [formGroup]="form" afbAutoErrors>
...
</form>
<!-- manual mode: trigger from code -->
<form [formGroup]="form" [afbAutoErrors]="'manual'">
...
</form>
// in your component
this.form.showValidationErrors();
// or without HTML injection
this.form.showValidationErrors({ generateHtml: false });
ForFormArray – structural directive to iterate over a FormArray by path on the parent FormGroup:
<form [formGroup]="form">
<div *ForFormArray="'addresses'; let group; index as i">
<div [formGroup]="group">
<input formControlName="street" />
<button type="button" (click)="addressesArray().removeAt(i)">Remove</button>
</div>
</div>
</form>
By default, it uses Bootstrap 5 classes:
is-invalid for invalid form controlsinvalid-feedback for auto-generated error containersYou can also customize the CSS classes used for invalid controls and auto-generated error containers:
ValidationManager.setCssConfig({
invalidClass: 'my-invalid-class',
errorContainerClass: 'my-error-container'
});
These helpers let you standardize error output while still giving you full control over localization, styling, and UI.
When generateValidationUiHelpers: true, the generator also creates AutoFormsBuilderModule.ts, which exposes a ready-to-use Angular module for all validation UI features.
ValidationErrorPipe and validationErrors pipe<afb-validation-errors> componentafbAutoErrors directiveForFormArray structural directiveinit method.import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { AutoFormsBuilderModule, AutoFormsBuilderConfig } from './forms/AutoFormsBuilderModule';
@NgModule({
imports: [
ReactiveFormsModule,
AutoFormsBuilderModule.init({
cssConfig: {
invalidClass: 'is-invalid',
errorContainerClass: 'invalid-feedback'
},
messageResolver: ({ key, error, defaultTemplate }) => {
const translationKey = `validation.${key}`;
return myTranslate(translationKey, error) ?? defaultTemplate;
},
globalTemplate: {
template: (control, element, messages) => `
<div class="invalid-feedback">
ErrorPlaceHolder
</div>
`,
placeholders: {
message: 'ErrorPlaceHolder'
}
}
} as AutoFormsBuilderConfig)
],
bootstrap: [AppComponent]
})
export class AppModule {}
In feature modules, import the module without calling init again:
@NgModule({
imports: [AutoFormsBuilderModule]
})
export class FeatureModule {}
Generated ValidationManager.ts now includes a typed FormValidator<T> class that wraps all validation utilities with IntelliSense:
import { FormValidator } from './forms/ValidationManager';
// Create a typed validator for your model
const v = new FormValidator<UserModel>(this.userFB.form);
// All errors in the form (flat list)
const allErrors = v.getAllErrors();
// [{ field: 'email', path: 'email', errors: { required: true }, messages: ['This field is required'] }]
// Check validity + mark touched in one call
if (v.isFormValid(true)) {
this.submit();
}
// Error count (for badges)
const count = v.errorCount(); // 3
// First invalid field path (for scroll)
const path = v.getFirstInvalidField(); // 'email'
// Observable of all errors (debounced)
v.errorMessages$().subscribe(errors => {
this.errorList = errors; // auto-updates on any change
});
// Apply server errors from API 400 response
v.applyServerErrors({ email: 'Already taken', password: 'Too weak' });
v.clearServerErrors();
// Dispose all subscriptions on destroy
ngOnDestroy() { v.dispose(); }
import { ValidationManager } from './forms/ValidationManager';
ValidationManager.getAllErrors(this.form);
ValidationManager.errorCount(this.form);
ValidationManager.getFirstInvalidField(this.form);
ValidationManager.isFormValid(this.form, true);
ValidationManager.errorMessages$(this.form).subscribe(...);
The most common form boilerplate — make fields required/validated only when another field has a certain value:
const v = new FormValidator<UserModel>(this.form);
// Company name required only when account type is 'business'
v.setRequiredIf(a => a.companyName, a => a.accountType, 'business');
// Add custom validators conditionally
v.conditionalValidators(
a => a.taxId, // target field
a => a.accountType, // source field
val => val === 'business', // condition
[Validators.required, Validators.minLength(9)]
);
// Cross-field rule: end date must be after start date
v.crossFieldRule(
[a => a.startDate, a => a.endDate],
form => {
const start = form.get('startDate')?.value;
const end = form.get('endDate')?.value;
return start && end && end <= start ? { dateRange: 'End must be after start' } : null;
},
a => a.endDate // apply error to this field
);
ValidationManager.setRequiredIf(this.form, 'companyName', 'accountType', 'business');
ValidationManager.conditionalValidators(
this.form, 'taxId', 'accountType',
v => v === 'business',
[Validators.required]
);
ValidationManager.crossFieldRule(
this.form,
['startDate', 'endDate'],
form => { ... },
'endDate'
);
<afb-validation-summary> — Show all errors in one place<afb-validation-summary [form]="form"></afb-validation-summary>
<afb-validation-summary [form]="form" [showFieldName]="false"></afb-validation-summary>
afbFocusOnError — Auto-scroll to first invalid field on submit<form [formGroup]="form" afbFocusOnError (ngSubmit)="submit()">
<!-- On invalid submit, scrolls to and focuses the first invalid input -->
</form>
Every error message now includes an i18nKey for integration with translation systems:
// Register with i18n key
ValidationManager.addErrorType('PhoneNumber', 'phoneNumber', 'Invalid phone number', 'validation.phoneNumber');
// Access i18n keys in your resolver
ValidationManager.setMessageResolver(({ key, code, error, defaultTemplate }) => {
const i18nKey = ValidationManager.getI18nKey(key) ?? 'validation.' + code;
return this.translate.instant(i18nKey, error) ?? defaultTemplate;
});
Every generated form builder includes lambda-based methods that provide full IntelliSense in VS Code:
// Get a control — autocompletes all model properties
const emailCtrl = userFB.getControl(a => a.email);
// Set a value with type safety
userFB.setFieldValue(a => a.email, 'new@example.com');
// Watch a single field's changes
userFB.watchField(a => a.email).subscribe(value => {
console.log('Email changed:', value);
});
These work in both the classic and signal-based templates.
Built-in submit lifecycle management — validates, marks touched if invalid, tracks loading state:
// In your component
save() {
this.userFB.submit(val => this.http.post<User>('/api/users', val))
.subscribe({
next: (saved) => console.log('Done!', saved),
error: (err) => console.error(err)
});
}
<!-- Disable button while submitting -->
<button [disabled]="userFB.isSubmitting">
{{ userFB.isSubmitting ? 'Saving...' : 'Save' }}
</button>
For signal templates, isSubmittingSignal is also available as a readonly signal.
// Mark all controls as touched + dirty (trigger all validation messages)
userFB.markAllTouched();
// Disable entire form (view-only mode)
userFB.disableAll();
// Re-enable all controls
userFB.enableAll();
Save and restore form state to localStorage or sessionStorage:
// Save current form state
userFB.persistTo('user-draft');
// Restore on page load (returns true if data was found)
const restored = userFB.restoreFrom('user-draft');
// Clear after successful save
userFB.clearPersisted('user-draft');
// Use sessionStorage instead
userFB.persistTo('user-draft', sessionStorage);
Create a new FormGroup with the same values as the current form:
const clonedForm = userFB.clone();
Auto-generated UnsavedChangesGuard.ts prevents navigation away from dirty forms:
// app-routing.module.ts
import { UnsavedChangesGuard } from './forms/UnsavedChangesGuard';
const routes: Routes = [
{
path: 'user/edit',
component: UserEditComponent,
canDeactivate: [UnsavedChangesGuard]
}
];
// user-edit.component.ts — implement the HasUnsavedChanges interface
import { HasUnsavedChanges } from './forms/UnsavedChangesGuard';
export class UserEditComponent implements HasUnsavedChanges {
canDeactivateMessage = 'You have unsaved changes. Leave anyway?';
constructor(public formBuilder: UserFormBuilder) {}
}
Generated FormProviders.ts eliminates manual provider arrays:
import { provideAllFormBuilders, provideFormBuilders } from './forms/FormProviders';
// Provide ALL form builders at once
@Component({
providers: provideAllFormBuilders()
})
export class AppComponent {}
// Or provide only specific ones
@Component({
providers: provideFormBuilders(UserFormBuilder, OrderFormBuilder)
})
export class UserPageComponent {}
Enable with "generateFormWizard": true. Generates a generic FormWizard<T> class with:
import { FormWizard } from './forms/FormWizard';
// Define steps with IntelliSense — VS Code autocompletes model properties
const wizard = new FormWizard<User>(userFormBuilder)
.setStep(1, [a => a.userName, a => a.fullName], 'Account Info')
.setStep(2, [a => a.email, a => a.phone], 'Contact')
.setStep(3, [a => a.password, a => a.repeatPassword], 'Security');
wizard.buildForm();
wizard.next(); // validates current step, moves forward
wizard.back(); // moves back
wizard.goToStep(2); // jump (validates all preceding steps)
wizard.progress; // 33, 66, 100 (percentage)
wizard.isFirstStep; // boolean
wizard.isLastStep; // boolean
wizard.canProceed(); // true if current step is valid
// Returns a FormGroup with only step 1 controls — synced with main form
const step1Form = wizard.buildStep(1, { userName: 'John' });
// Add custom controls not in the model
const step2Form = wizard.buildStep(2, null, [
{ name: 'agreedToTerms', control: new FormControl(false, Validators.requiredTrue) }
]);
<form [formGroup]="step1Form">
<input formControlName="userName" />
<input formControlName="fullName" />
</form>
// Get structured errors for a step
wizard.getStepErrors(1);
// → [{ field: 'email', errors: { required: true } }]
// React to step transitions
wizard.onStepChange.subscribe(e => {
console.log(`Step ${e.from} → ${e.to}`);
});
// Typed field access on wizard
wizard.getControl(a => a.email);
wizard.setFieldValue(a => a.email, 'x@y.com');
wizard.watchField(a => a.email).subscribe(v => ...);
// Cleanup
wizard.dispose();
wizard.addControlToStep(1, a => a.email, new FormControl(''));
wizard.removeControlFromStep(1, a => a.email);
// Or plain string for custom (non-model) controls
wizard.addControlToStep(1, 'captchaToken', new FormControl());
Generated DateHelper.ts provides zero-dependency date utilities commonly needed in form applications:
import { dateHelper } from './forms/DateHelper';
// Convert Date to <input type="date"> value
dateHelper.toFormControlValue(new Date()); // '2024-05-15'
// Convert <input type="date"> value back to Date
dateHelper.fromFormControlValue('2024-05-15'); // Date object
// ISO date-only for API payloads (no time component)
dateHelper.toISODateOnly(new Date()); // '2024-05-15'
// Format with simple tokens
dateHelper.format(new Date(), 'yyyy-MM-dd'); // '2024-05-15'
dateHelper.format(new Date(), 'dd/MM/yyyy HH:mm'); // '15/05/2024 14:30'
// Parse reliably — handles yyyy-MM-dd, dd/MM/yyyy, ISO 8601
dateHelper.parse('2024-05-15'); // Date
dateHelper.parse('15/05/2024'); // Date
dateHelper.parse('invalid'); // null
dateHelper.isValid('2024-02-30'); // false
dateHelper.isValid(new Date()); // true
dateHelper.isBefore(dateA, dateB); // true/false (ignores time)
dateHelper.isAfter(dateA, dateB); // true/false
dateHelper.isBetween(date, startDate, endDate); // inclusive range check
dateHelper.startOfDay(new Date()); // 2024-05-15 00:00:00.000
dateHelper.endOfDay(new Date()); // 2024-05-15 23:59:59.999
dateHelper.addDays(date, 7);
dateHelper.addMonths(date, 3);
dateHelper.addYears(date, 1);
dateHelper.diffInDays(date1, date2);
dateHelper.diffInMonths(date1, date2);
dateHelper.diffInYears(date1, date2);
FormWizard<T> with fluent .setStep() API, Proxy-based IntelliSense field selectors, step validation, buildStep(), onStepChange, getStepErrors().getControl(a => a.field), setFieldValue(a => a.field, val), watchField(a => a.field) on all form builders.submit(saveFn) with auto isSubmitting management.markAllTouched(), disableAll(), enableAll().clone() creates a new FormGroup with current values.persistTo(key) / restoreFrom(key) / clearPersisted(key) for drafts.CanDeactivate guard.provideAllFormBuilders() / provideFormBuilders(...) barrel.<form [formGroup]="myFormGroup">
<!-- Other form elements -->
<div showForError="required" formControlName="controlName">
This will show if 'invalidCharacters' error exists.
</div>
<!-- Other form elements -->
</form>
{prop}Ctrl getter and {prop}ValueChanges$ observable generated for every field.addNew{Prop}(), delete{Prop}ByIndex(), {prop}Array(), {prop}Controls(), {prop}Value generated for all array properties.getControl(a => a.field), setFieldValue(...), watchField(...) across all builders and wizard.submit(saveFn) validates, tracks loading, handles errors.canDeactivate array.providers.This repository includes an LLM file at the project root. It is a plain-text guide for AI coding assistants (LLMs) that:
ng-openapi-gen.IParsedConfig), the template system, and the generated runtime files.The LLM file is not used at runtime. It exists only to help AI tools understand the project and make safer, more accurate changes when you ask them to modify this codebase.
FAQs
Accelerate Angular Development with Auto Reactive Forms Builder Generator Based On Open API
The npm package angular-formsbuilder-gen receives a total of 0 weekly downloads. As such, angular-formsbuilder-gen popularity was classified as not popular.
We found that angular-formsbuilder-gen demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
A Shai-Hulud infection exposed Suno's source code, which shows the AI music startup stream-ripped tracks to train its models.

Security News
Vercel is formalizing a monthly release program for Next.js. The change follows React2Shell and a sharp rise in AI-assisted vulnerability discovery.

Research
/Security News
11 malicious NuGet tools pose as game cheats to deploy Windows payloads, track hosts, and use Google Sheets for telemetry and control.