Merge pull request #336 from trails-cool/stigi/wahoo-push-apply

Add @trails-cool/fit GPX→FIT Course encoder
This commit is contained in:
Ullrich Schäfer 2026-04-30 22:35:31 +02:00 committed by GitHub
commit 19f4c45c74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 384 additions and 18 deletions

View file

@ -21,6 +21,7 @@
"@trails-cool/api": "workspace:*",
"@trails-cool/db": "workspace:*",
"@trails-cool/jobs": "workspace:*",
"@trails-cool/fit": "workspace:*",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*",

View file

@ -23,17 +23,22 @@ There is no FIT *encoder* in the repo today — we only decode. FIT Course encod
## Decisions
### 1. Use a hand-rolled FIT Course encoder, not the official Garmin SDK
### 1. Use Garmin's official `@garmin/fitsdk` package server-side
**Decision**: Add a small (~300 LOC) hand-rolled FIT encoder to `packages/fit/` that emits exactly the message types Wahoo needs for a Course: `file_id`, `file_creator`, `course`, `lap`, `event` (start/stop), and `record`. No dependency on Garmin's SDK.
**Decision**: Depend on `@garmin/fitsdk` (the renamed, current package — formerly published as `@garmin/fitsdk-javascript`) and wrap it in `packages/fit/` with a single `gpxToFitCourse(...)` export. Encoding runs server-side in the Journal action route only — the SDK does not ship to the planner browser bundle.
**Why**:
- `@garmin/fitsdk-javascript` is the official option but ships ~1 MB of profile metadata and a CommonJS-only build that fights our ESM/Vite setup. Pulling it into the journal bundle is wasteful when we use ~5 message types.
- `fit-file-writer` (npm) is the closest community option, but unmaintained since 2021 and ships TypeScript types that disagree with the runtime API. We'd be patching it on day one.
- The FIT binary format is a documented, stable protocol (header + variable-length records + CRC). Encoding the seven messages we need is small enough to own.
- `fit-file-parser` (used today on the import path) gives us a free correctness oracle: every encoded file gets round-tripped through the parser in tests and asserted to match the source GPX track points.
**Why** (revised after re-evaluating the SDK as of 2026-04):
- The SDK is now **pure ESM** (`"type": "module"`, zero deps). The earlier ESM/Vite friction is gone.
- Actively maintained by Garmin, published quarterly in lockstep with the FIT profile (latest 21.202.0, Apr 2026).
- First-class Course encoder API (`new Encoder()` + `writeMesg(...)` + `close()``Uint8Array`) with an official cookbook at <https://developer.garmin.com/fit/cookbook/encoding-course-files/>.
- The ~1 MB unpacked size is real but irrelevant: encoding lives behind `/api/sync/push/wahoo/:routeId` (Node 20). Nothing in the planner imports `@trails-cool/fit`.
- Avoids ~400 LOC of binary plumbing (file header, definition messages, data messages, CRC-16) that we'd otherwise own and maintain against future FIT spec updates.
**Alternatives considered**: `@garmin/fitsdk-javascript` (rejected: bundle size, ESM friction), `fit-file-writer` (rejected: unmaintained), shelling out to the Python `fit-tool` (rejected: adds a runtime dependency on the deploy host that doesn't already exist).
**Round-trip oracle**: `fit-file-parser` (already a dep on the import path) decodes every encoded fixture in tests and asserts track-point parity with the source GPX. Same correctness story as before — just with less code in our repo.
**Cost**: TypeScript types aren't shipped, so `packages/fit/` includes a small ambient `fitsdk.d.ts` (~30 lines covering `Encoder`, `Stream`, `Profile.MesgNum`).
**Alternatives considered**: hand-rolled encoder (rejected: 400 LOC of binary format we'd own forever, plus ongoing maintenance against FIT spec updates — net loss now that the SDK's ESM/maintenance objections are resolved); `fit-file-writer` (rejected: appears unpublished from npm registry); shelling out to Python `fit-tool` (rejected: new runtime dependency on the deploy host).
### 2. New `packages/fit/` package, GPX-shaped public API

View file

@ -27,7 +27,7 @@ The existing Wahoo integration is read-only: we pull completed workouts back int
- `apps/journal/app/routes/` — new `/api/sync/push/wahoo/:routeId` action; "Send to Wahoo" button on the route detail page.
- `packages/db/src/schema/journal.ts` — new `sync_pushes` table + `granted_scopes` column on `sync_connections`, plus the generated Drizzle migration in `packages/db/migrations/`.
- **New package** `packages/fit/` — GPX→FIT Course encoder, co-located tests with FIT-decoder round-trip fixtures.
- **Dependencies**: One new dep — a JS FIT encoder. Candidates to evaluate in design.md: `@garmin/fitsdk-javascript` (official, large), `fit-file-writer`, or a thin hand-rolled encoder against the FIT SDK profile (Course message + Record + Lap).
- **Dependencies**: One new dep — `@garmin/fitsdk` (Garmin's official ESM-native FIT SDK; see design.md §1 for evaluation). `fit-file-parser` is already a dep on the import path and is reused as the round-trip oracle in tests.
- **External API**: Net-new outbound calls to `api.wahooligan.com/v1/routes`. No webhook changes.
- **Privacy manifest**: Update `docs/privacy.md` (or wherever the manifest lives) to declare that route geometry + name + description are sent to Wahoo when the user opts in by clicking "Send to Wahoo".
- **Out of scope for this change**: bulk push ("send all my routes"), automatic push on route save, push to non-Wahoo providers, and pushing route updates after the initial send (PUT /v1/routes/:id can come later — first send wins).

View file

@ -1,13 +1,13 @@
## 1. FIT Course encoder package
- [ ] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/`
- [ ] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json`
- [ ] 1.3 Implement low-level FIT primitives: file header, definition messages, data messages, CRC-16. Cover only the field types needed (`enum`, `uint8`, `uint16`, `uint32`, `sint32`, `string`, `float32`)
- [ ] 1.4 Implement message encoders for `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance)
- [ ] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, emit the message stream, return `Uint8Array`
- [ ] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw)
- [ ] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m)
- [ ] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo)
- [x] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/`
- [x] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json`
- [x] 1.3 Add `@garmin/fitsdk` dependency to `packages/fit/package.json` and a small ambient `src/fitsdk.d.ts` declaring the `Encoder`, `Stream`, and `Profile.MesgNum` shapes we use (the SDK ships no types)
- [x] 1.4 Wrap the SDK Encoder to emit the Course message stream Wahoo needs: `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance)
- [x] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, drive the SDK encoder, return `Uint8Array`
- [x] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw)
- [x] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m)
- [x] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo)
## 2. Database schema for push tracking
@ -59,7 +59,7 @@
## 9. Tests
- [ ] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
- [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
- [ ] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
- [ ] 9.3 Unit tests for the scope-mismatch redirect flow
- [ ] 9.4 E2E test in `e2e/`: log in as a user with a (mocked) Wahoo connection, open a route detail page, click "Send to Wahoo", assert the success toast and the "Sent to Wahoo on …" status appear

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Alpine climb</name></metadata>
<trk><trkseg>
<trkpt lat="46.5000" lon="11.3000"><ele>800.0</ele></trkpt>
<trkpt lat="46.5050" lon="11.3050"><ele>950.5</ele></trkpt>
<trkpt lat="46.5100" lon="11.3100"><ele>1100.2</ele></trkpt>
<trkpt lat="46.5150" lon="11.3150"><ele>1280.7</ele></trkpt>
<trkpt lat="46.5200" lon="11.3200"><ele>1450.0</ele></trkpt>
<trkpt lat="46.5250" lon="11.3250"><ele>1620.3</ele></trkpt>
<trkpt lat="46.5300" lon="11.3300"><ele>1800.0</ele></trkpt>
<trkpt lat="46.5350" lon="11.3350"><ele>1750.0</ele></trkpt>
<trkpt lat="46.5400" lon="11.3400"><ele>1600.0</ele></trkpt>
<trkpt lat="46.5450" lon="11.3450"><ele>1400.0</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Empty</name></metadata>
<trk><trkseg></trkseg></trk>
</gpx>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Multi-day tour</name></metadata>
<trk><trkseg>
<trkpt lat="47.0000" lon="10.0000"><ele>500</ele></trkpt>
<trkpt lat="47.0500" lon="10.0500"><ele>650</ele></trkpt>
<trkpt lat="47.1000" lon="10.1000"><ele>800</ele></trkpt>
<trkpt lat="47.1500" lon="10.1500"><ele>720</ele></trkpt>
<trkpt lat="47.2000" lon="10.2000"><ele>600</ele></trkpt>
<trkpt lat="47.2500" lon="10.2500"><ele>550</ele></trkpt>
<trkpt lat="47.3000" lon="10.3000"><ele>700</ele></trkpt>
<trkpt lat="47.3500" lon="10.3500"><ele>900</ele></trkpt>
<trkpt lat="47.4000" lon="10.4000"><ele>1100</ele></trkpt>
<trkpt lat="47.4500" lon="10.4500"><ele>1050</ele></trkpt>
<trkpt lat="47.5000" lon="10.5000"><ele>900</ele></trkpt>
<trkpt lat="47.5500" lon="10.5500"><ele>750</ele></trkpt>
<trkpt lat="47.6000" lon="10.6000"><ele>600</ele></trkpt>
<trkpt lat="47.6500" lon="10.6500"><ele>500</ele></trkpt>
<trkpt lat="47.7000" lon="10.7000"><ele>450</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Short flat loop</name></metadata>
<trk><name>Short flat loop</name><trkseg>
<trkpt lat="52.5200" lon="13.4050"><ele>34.0</ele></trkpt>
<trkpt lat="52.5210" lon="13.4060"><ele>34.5</ele></trkpt>
<trkpt lat="52.5220" lon="13.4070"><ele>35.0</ele></trkpt>
<trkpt lat="52.5230" lon="13.4080"><ele>34.5</ele></trkpt>
<trkpt lat="52.5240" lon="13.4090"><ele>34.0</ele></trkpt>
</trkseg></trk>
</gpx>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Single point</name></metadata>
<trk><trkseg>
<trkpt lat="48.8566" lon="2.3522"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>

23
packages/fit/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@trails-cool/fit",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"@garmin/fitsdk": "^21.202.0",
"@trails-cool/gpx": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"fit-file-parser": "^2.3.3"
}
}

39
packages/fit/src/fitsdk.d.ts vendored Normal file
View file

@ -0,0 +1,39 @@
declare module "@garmin/fitsdk" {
export class Encoder {
constructor(options?: { fieldDescriptions?: unknown });
writeMesg(mesg: { mesgNum: number; [field: string]: unknown }): this;
onMesg(mesgNum: number, mesg: Record<string, unknown>): this;
close(): Uint8Array;
}
export class Stream {
static fromArrayBuffer(buf: ArrayBuffer): Stream;
static fromBuffer(buf: Uint8Array | Buffer): Stream;
}
export class Decoder {
constructor(stream: Stream);
read(opts?: Record<string, unknown>): {
messages: Record<string, Array<Record<string, unknown>>>;
errors: unknown[];
};
}
export const Profile: {
version: { major: number; minor: number };
MesgNum: {
FILE_ID: number;
FILE_CREATOR: number;
COURSE: number;
LAP: number;
RECORD: number;
EVENT: number;
[key: string]: number;
};
};
export const Utils: {
convertDateToDateTime(date: Date): number;
convertDateTimeToDate(dt: number): Date;
};
}

View file

@ -0,0 +1,76 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import FitParser from "fit-file-parser";
import { parseGpxAsync } from "@trails-cool/gpx";
import { gpxToFitCourse } from "./gpx-to-fit-course.ts";
const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../__fixtures__");
async function loadFixture(name: string): Promise<string> {
return readFile(resolve(FIXTURES_DIR, name), "utf8");
}
interface ParsedFit {
records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>;
course?: Array<{ name?: string; sport?: string }> | { name?: string; sport?: string };
laps?: Array<unknown>;
}
function decode(bytes: Uint8Array): Promise<ParsedFit> {
return new Promise((res, rej) => {
const parser = new FitParser({ force: true, mode: "list", lengthUnit: "m", speedUnit: "m/s" });
parser.parse(Buffer.from(bytes), (err, data) => {
if (err) rej(new Error(err));
else res(data as unknown as ParsedFit);
});
});
}
const FIXTURES = ["short-flat.gpx", "alpine.gpx", "multi-day.gpx", "single-point.gpx"] as const;
describe("gpxToFitCourse", () => {
for (const fixture of FIXTURES) {
it(`encodes ${fixture} round-trip via fit-file-parser`, async () => {
const gpx = await loadFixture(fixture);
const source = await parseGpxAsync(gpx);
const sourcePoints = source.tracks.flat();
const bytes = await gpxToFitCourse({ gpx, name: `Test ${fixture}` });
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.byteLength).toBeGreaterThan(20);
const parsed = await decode(bytes);
const records = parsed.records ?? [];
expect(records.length).toBe(sourcePoints.length);
for (let i = 0; i < sourcePoints.length; i++) {
const src = sourcePoints[i]!;
const got = records[i]!;
expect(got.position_lat).toBeCloseTo(src.lat, 4);
expect(got.position_long).toBeCloseTo(src.lon, 4);
if (src.ele !== undefined && got.altitude !== undefined) {
expect(Math.abs(got.altitude - src.ele)).toBeLessThan(0.5);
}
}
});
}
it("throws on a GPX with zero track points", async () => {
const gpx = await loadFixture("empty.gpx");
await expect(gpxToFitCourse({ gpx, name: "Empty" })).rejects.toThrow(/zero track points/);
});
it("encodes course name and sport", async () => {
const gpx = await loadFixture("short-flat.gpx");
const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" });
const parsed = await decode(bytes);
const course = Array.isArray(parsed.course) ? parsed.course[0] : parsed.course;
expect(course?.name).toBe("Loop Test");
expect(course?.sport).toBe("cycling");
});
});

View file

@ -0,0 +1,117 @@
import { Encoder, Profile } from "@garmin/fitsdk";
import { parseGpxAsync } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx";
import { degToSemicircles } from "./semicircles.ts";
export type FitCourseSport = "cycling" | "running" | "hiking";
export interface GpxToFitCourseInput {
gpx: string;
name: string;
description?: string;
sport?: FitCourseSport;
}
const SPORT_ENUM: Record<FitCourseSport, string> = {
cycling: "cycling",
running: "running",
hiking: "hiking",
};
export async function gpxToFitCourse(input: GpxToFitCourseInput): Promise<Uint8Array> {
const data = await parseGpxAsync(input.gpx);
const points: TrackPoint[] = data.tracks.flat();
if (points.length === 0) {
throw new Error("Cannot encode FIT Course from a GPX with zero track points");
}
const first = points[0]!;
const last = points[points.length - 1]!;
const startTime = new Date("2020-01-01T00:00:00Z");
const encoder = new Encoder();
encoder.writeMesg({
mesgNum: Profile.MesgNum.FILE_ID,
type: "course",
manufacturer: "development",
product: 0,
timeCreated: startTime,
serialNumber: 0,
});
encoder.writeMesg({
mesgNum: Profile.MesgNum.FILE_CREATOR,
softwareVersion: 1,
hardwareVersion: 0,
});
encoder.writeMesg({
mesgNum: Profile.MesgNum.COURSE,
name: input.name,
sport: SPORT_ENUM[input.sport ?? "cycling"],
capabilities: 0x00000004,
});
encoder.writeMesg({
mesgNum: Profile.MesgNum.LAP,
timestamp: startTime,
startTime,
startPositionLat: degToSemicircles(first.lat),
startPositionLong: degToSemicircles(first.lon),
endPositionLat: degToSemicircles(last.lat),
endPositionLong: degToSemicircles(last.lon),
totalElapsedTime: 0,
totalTimerTime: 0,
totalDistance: data.distance,
});
encoder.writeMesg({
mesgNum: Profile.MesgNum.EVENT,
timestamp: startTime,
event: "timer",
eventType: "start",
});
let cumulativeDistance = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
if (i > 0) {
const prev = points[i - 1]!;
cumulativeDistance += haversine(prev.lat, prev.lon, p.lat, p.lon);
}
const mesg: { mesgNum: number; [field: string]: unknown } = {
mesgNum: Profile.MesgNum.RECORD,
timestamp: new Date(startTime.getTime() + i * 1000),
positionLat: degToSemicircles(p.lat),
positionLong: degToSemicircles(p.lon),
distance: cumulativeDistance,
};
if (p.ele !== undefined) {
mesg.altitude = p.ele;
}
encoder.writeMesg(mesg);
}
encoder.writeMesg({
mesgNum: Profile.MesgNum.EVENT,
timestamp: new Date(startTime.getTime() + (points.length - 1) * 1000),
event: "timer",
eventType: "stopAll",
});
return encoder.close();
}
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

View file

@ -0,0 +1,2 @@
export { gpxToFitCourse } from "./gpx-to-fit-course.ts";
export type { FitCourseSport, GpxToFitCourseInput } from "./gpx-to-fit-course.ts";

View file

@ -0,0 +1,9 @@
const SEMICIRCLES_PER_DEGREE = 2 ** 31 / 180;
export function degToSemicircles(deg: number): number {
return Math.round(deg * SEMICIRCLES_PER_DEGREE);
}
export function semicirclesToDeg(semi: number): number {
return semi / SEMICIRCLES_PER_DEGREE;
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

24
pnpm-lock.yaml generated
View file

@ -218,6 +218,9 @@ importers:
'@trails-cool/db':
specifier: workspace:*
version: link:../../packages/db
'@trails-cool/fit':
specifier: workspace:*
version: link:../../packages/fit
'@trails-cool/gpx':
specifier: workspace:*
version: link:../../packages/gpx
@ -592,6 +595,22 @@ importers:
specifier: 'catalog:'
version: 22.19.17
packages/fit:
dependencies:
'@garmin/fitsdk':
specifier: ^21.202.0
version: 21.202.0
'@trails-cool/gpx':
specifier: workspace:*
version: link:../gpx
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 22.19.17
fit-file-parser:
specifier: ^2.3.3
version: 2.3.3
packages/gpx:
dependencies:
'@trails-cool/types':
@ -1928,6 +1947,9 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.9.0
'@garmin/fitsdk@21.202.0':
resolution: {integrity: sha512-WeveKUCKZSehRdM6JY/n0embblH18z5sY0KkJye5Z9nJ8Qv2S6EEmoSuBrW2ew45OtTGph0bu4/jxlO6VRP+Qw==}
'@geoman-io/leaflet-geoman-free@2.19.3':
resolution: {integrity: sha512-HjbEpfAEUs0NyI1Dhvz3SMVG6m0pAN/1Eo0tRKsz9cpaROTrFtmJGY22swEir1Uj/8IeGF1NJId38C5Fu+nZGQ==}
engines: {node: '>=18.0.0'}
@ -8942,6 +8964,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@garmin/fitsdk@21.202.0': {}
'@geoman-io/leaflet-geoman-free@2.19.3(leaflet@1.9.4)':
dependencies:
'@turf/boolean-contains': 7.3.4