Initial commit: parksmarter-client + BigBrainParking app

Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a
de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS,
VisionCamera QR kiosk scanning with save/share, local session-expiry reminders,
UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 08:33:47 -07:00
commit 1dede50995
39 changed files with 3671 additions and 0 deletions

View file

@ -0,0 +1,62 @@
# Build a signed release APK and publish it as a Gitea/Forgejo release.
# Obtainium then tracks this repo's releases and offers updates.
#
# Triggers on any tag like v0.1.0. Requires a self-hosted Actions runner that has
# (or a container image that provides) the Android SDK + JDK 17 + Node 20.
#
# Required repo secrets (Settings -> Actions -> Secrets):
# ANDROID_KEYSTORE_B64 base64 of your release keystore (keep this key forever —
# Obtainium/F-Droid pin the signer; a new key = users must reinstall)
# ANDROID_KEYSTORE_PASSWORD
# ANDROID_KEY_ALIAS
# ANDROID_KEY_PASSWORD
#
# Generate the keystore once, locally:
# keytool -genkeypair -v -keystore bigbrainparking.keystore \
# -alias bigbrainparking -keyalg RSA -keysize 2048 -validity 10000
# base64 -w0 bigbrainparking.keystore # -> ANDROID_KEYSTORE_B64
name: build-apk
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
# A container with Android SDK + Node preinstalled keeps the runner simple:
container:
image: reactnativecommunity/react-native-android:latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm install
- name: Build the API client
run: npm run build --workspace parksmarter-client
- name: Restore signing keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "$PWD/app/release.keystore"
- name: Expo prebuild (generate android/)
working-directory: app
run: npx expo prebuild --platform android --no-install
- name: Assemble signed release
working-directory: app/android
env:
BBP_UPLOAD_STORE_FILE: ../release.keystore
BBP_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
BBP_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
BBP_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
- name: Publish release with APK
uses: akkuman/gitea-release-action@v1
with:
files: app/android/app/build/outputs/apk/release/*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Dependencies
node_modules/
# Builds
dist/
app/android/
app/ios/
app/.expo/
*.apk
*.aab
# Secrets / local capture
**/.creds.json
**/capture/
*.keystore
*.jks
# The original app artifact (local only, not source)
*.xapk
# OS / editor
.DS_Store
*.log

66
README.md Normal file
View file

@ -0,0 +1,66 @@
# BigBrainParking
An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system,
built to run on **GrapheneOS** with **UnifiedPush** notifications and distributed
via **Obtainium** — no Google Play Services, no Firebase, no app store.
## Monorepo layout
```
bigbrainparking/
├── parksmarter-client/ # Zero-dep TypeScript API client (reverse-engineered, live-verified)
├── app/ # Expo / React Native app (BigBrainParking)
└── .gitea/workflows/ # CI: build signed APK -> publish release for Obtainium
```
- **`parksmarter-client`** — the API layer. All ~40 endpoints, the custom-header auth
model, and response models (most verified against production). Runs anywhere; the app
imports it directly. See its own README for the API details.
- **`app`** — the phone app. React Native (Expo prebuild), MapLibre maps, VisionCamera QR
scanning, expo-secure-store token storage, local session-expiry reminders, and
UnifiedPush wiring.
## Features
| Feature | Status | Notes |
| --- | --- | --- |
| Phone + password login | ✅ wired | tokens in OS keystore |
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
| "Near my last location" proximity search | ✅ wired | caches last GPS fix |
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
| Active / past sessions | ✅ wired | list views |
| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) |
| Session-expiry reminders | ✅ wired | **local** notifications — no FCM needed |
| UnifiedPush (ntfy) | 🟡 partial | endpoint registration done; server→ntfy bridge still needed (see below) |
## Build & run (dev)
Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device)
with USB debugging.
```bash
npm install # installs both workspaces
npm run build --workspace parksmarter-client # compile the client
cd app
npx expo prebuild --platform android # generate native project
npx expo run:android # build + install a dev client
```
The app talks to prod (`apiv2.parksmarter.com`) by default — change `extra.psEnvironment`
in `app/app.json` to point elsewhere.
## Notifications on GrapheneOS
Session-expiry reminders are scheduled **locally** from each session's end time, so they
need no push infrastructure and work fully offline of Google. `app/src/notifications/`
also wires **UnifiedPush** (distributor: ntfy) for any genuinely server-initiated push —
but note the ParkSmarter backend only pushes via **FCM**, so server push requires a small
**FCM→ntfy bridge** (a service holding an FCM token that forwards to your ntfy topic, then
registered via `PUT /api/Device`). Until that exists, local reminders cover the main case.
## Distribution via Obtainium (self-hosted)
Tag a release and CI builds a signed APK and publishes it to this repo's releases; your
phone's Obtainium tracks the repo and offers updates. See
[`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for the full server + CI + keystore setup.

16
app/App.tsx Normal file
View file

@ -0,0 +1,16 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from '@/auth/AuthContext';
import { RootNavigator } from '@/navigation/RootNavigator';
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<RootNavigator />
<StatusBar style="auto" />
</AuthProvider>
</SafeAreaProvider>
);
}

49
app/app.json Normal file
View file

@ -0,0 +1,49 @@
{
"expo": {
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"android": {
"package": "top.mowden.bigbrainparking",
"permissions": [
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"CAMERA",
"POST_NOTIFICATIONS",
"INTERNET",
"ACCESS_NETWORK_STATE"
]
},
"plugins": [
"expo-secure-store",
[
"expo-location",
{
"locationWhenInUsePermission": "Show nearby parking meters on the map."
}
],
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png"
}
],
[
"react-native-vision-camera",
{
"cameraPermissionText": "Scan the QR code on a parking kiosk."
}
],
"./plugins/withReleaseSigning"
],
"extra": {
"psEnvironment": "prodv2",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"unifiedPushDefaultDistributor": "io.heckel.ntfy"
}
}
}

14
app/babel.config.js Normal file
View file

@ -0,0 +1,14 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: { '@': './src' },
},
],
],
};
};

4
app/index.ts Normal file
View file

@ -0,0 +1,4 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);

20
app/metro.config.js Normal file
View file

@ -0,0 +1,20 @@
// Metro config for an Expo app inside an npm-workspaces monorepo.
// Lets Metro resolve and watch the sibling `parksmarter-client` package.
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '..');
const config = getDefaultConfig(projectRoot);
// Watch the whole monorepo so edits to parksmarter-client hot-reload.
config.watchFolders = [workspaceRoot];
// Resolve modules from both the app and the workspace root.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;

38
app/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "bigbrainparking-app",
"version": "0.1.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "expo start --dev-client",
"android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"parksmarter-client": "*",
"expo": "~53.0.0",
"expo-secure-store": "~14.0.0",
"expo-location": "~18.0.0",
"expo-notifications": "~0.29.0",
"expo-constants": "~17.0.0",
"expo-localization": "~16.0.0",
"expo-linking": "~7.0.0",
"expo-status-bar": "~2.0.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@react-navigation/bottom-tabs": "^7.0.0",
"react-native-screens": "~4.4.0",
"react-native-safe-area-context": "4.12.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@maplibre/maplibre-react-native": "^10.0.0",
"react-native-vision-camera": "^4.6.0",
"react-native-unifiedpush": "^2.0.0",
"react": "19.0.0",
"react-native": "0.79.0"
},
"devDependencies": {
"@types/react": "~19.0.0",
"typescript": "~5.4.0"
}
}

View file

@ -0,0 +1,46 @@
/**
* Expo config plugin: inject a release signingConfig into android/app/build.gradle
* that reads credentials from environment variables (set by CI). This survives
* `expo prebuild --clean`, so signed builds are reproducible without committing
* the generated android/ directory.
*
* Env vars (see .gitea/workflows/build-apk.yml):
* BBP_UPLOAD_STORE_FILE, BBP_UPLOAD_STORE_PASSWORD,
* BBP_UPLOAD_KEY_ALIAS, BBP_UPLOAD_KEY_PASSWORD
*
* If the env vars are absent (local dev), the build falls back to the debug key.
*/
const { withAppBuildGradle } = require('@expo/config-plugins');
const SIGNING_BLOCK = `
release {
def storeFilePath = System.getenv("BBP_UPLOAD_STORE_FILE")
if (storeFilePath != null) {
storeFile file(storeFilePath)
storePassword System.getenv("BBP_UPLOAD_STORE_PASSWORD")
keyAlias System.getenv("BBP_UPLOAD_KEY_ALIAS")
keyPassword System.getenv("BBP_UPLOAD_KEY_PASSWORD")
}
}`;
module.exports = function withReleaseSigning(config) {
return withAppBuildGradle(config, (cfg) => {
let gradle = cfg.modResults.contents;
// 1) Add a `release` signingConfig next to the default `debug` one.
if (!gradle.includes('BBP_UPLOAD_STORE_FILE')) {
gradle = gradle.replace(
/signingConfigs \{/,
`signingConfigs {${SIGNING_BLOCK}`,
);
// 2) Point the release buildType at it *only* when the keystore env is set.
gradle = gradle.replace(
/(buildTypes \{[\s\S]*?release \{)/,
`$1\n if (System.getenv("BBP_UPLOAD_STORE_FILE") != null) { signingConfig signingConfigs.release }`,
);
}
cfg.modResults.contents = gradle;
return cfg;
});
};

17
app/src/api/client.ts Normal file
View file

@ -0,0 +1,17 @@
import Constants from 'expo-constants';
import * as Localization from 'expo-localization';
import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client';
import { secureTokenStore } from './secureTokenStore';
const env =
(Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2';
/**
* The single app-wide API client. React Native ships a global `fetch`, so no
* fetchImpl override is needed. Tokens persist in the OS keystore.
*/
export const ps = new ParkSmarterClient({
environment: env,
tokens: secureTokenStore,
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
});

View file

@ -0,0 +1,24 @@
import * as SecureStore from 'expo-secure-store';
import type { TokenStore } from 'parksmarter-client';
/**
* A TokenStore backed by the OS keystore (expo-secure-store).
*
* On GrapheneOS/Android this uses the hardware-backed Keystore, so the user's
* Auth_Token and SessionId are encrypted at rest never plain AsyncStorage.
*/
const AUTH_KEY = 'ps_auth_token';
const SESSION_KEY = 'ps_session_id';
export const secureTokenStore: TokenStore = {
getAuthToken: () => SecureStore.getItemAsync(AUTH_KEY),
setAuthToken: (token) =>
token
? SecureStore.setItemAsync(AUTH_KEY, token)
: SecureStore.deleteItemAsync(AUTH_KEY),
getSessionId: () => SecureStore.getItemAsync(SESSION_KEY),
setSessionId: (id) =>
id
? SecureStore.setItemAsync(SESSION_KEY, id)
: SecureStore.deleteItemAsync(SESSION_KEY),
};

View file

@ -0,0 +1,78 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { ps } from '@/api/client';
import type { ApplicationValidityResponse } from 'parksmarter-client';
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
interface AuthState {
status: AuthStatus;
validity: ApplicationValidityResponse | null;
login: (phoneNumber: string, password: string) => Promise<void>;
logout: () => Promise<void>;
error: string | null;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<AuthStatus>('loading');
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// On launch: bootstrap (seeds SessionId + feature flags) and probe for an
// existing token by attempting an authenticated read.
useEffect(() => {
(async () => {
try {
const v = await ps.getApplicationValidity();
setValidity(v);
const existing = await ps.tokens.getAuthToken();
if (existing) {
await ps.getUserDetail(); // 401 throws -> treated as signed out
setStatus('signedIn');
} else {
setStatus('signedOut');
}
} catch {
setStatus('signedOut');
}
})();
}, []);
const login = async (phoneNumber: string, password: string) => {
setError(null);
try {
await ps.loginWithPhone({ phoneNumber, password });
setStatus('signedIn');
} catch (e: any) {
// A 201 from /api/Auth means the account needs SMS verification first.
if (e?.status === 201) setError('Account needs verification. Check your texts.');
else setError(e?.serverMessage ?? e?.message ?? 'Login failed.');
throw e;
}
};
const logout = async () => {
await ps.logoutLocal();
setStatus('signedOut');
};
const value = useMemo<AuthState>(
() => ({ status, validity, login, logout, error }),
[status, validity, error],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View file

@ -0,0 +1,79 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Share } from 'react-native';
import type { Zone } from 'parksmarter-client';
/**
* Saved kiosks live entirely on-device. The ParkSmarter server exposes no
* favorites API (the `FavoriteID` field never appears in live responses), so
* "save" and "share" are client-side features we own.
*/
export interface SavedKiosk {
/** Stable local id — the scanner code or serial identifies the kiosk. */
key: string;
zoneName?: string;
scannerCode?: string;
terminalSerNo?: string;
zoneId?: number;
customerId?: number;
lat?: number;
long?: number;
savedAt: number;
note?: string;
}
const KEY = 'ps_saved_kiosks';
export function kioskKey(z: Pick<Zone, 'ScannerCode' | 'TerminalSerNo' | 'ZoneId'>): string {
return String(z.ScannerCode ?? z.TerminalSerNo ?? z.ZoneId ?? 'unknown');
}
export function toSavedKiosk(z: Zone, note?: string): SavedKiosk {
return {
key: kioskKey(z),
zoneName: z.ZoneName,
scannerCode: z.ScannerCode,
terminalSerNo: z.TerminalSerNo,
zoneId: z.ZoneId,
customerId: z.CustomerId,
lat: z.Lat,
long: z.Long,
savedAt: Date.now(),
note,
};
}
export async function listKiosks(): Promise<SavedKiosk[]> {
const raw = await AsyncStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as SavedKiosk[]) : [];
}
export async function saveKiosk(k: SavedKiosk): Promise<SavedKiosk[]> {
const all = await listKiosks();
const next = [k, ...all.filter((x) => x.key !== k.key)];
await AsyncStorage.setItem(KEY, JSON.stringify(next));
return next;
}
export async function removeKiosk(key: string): Promise<SavedKiosk[]> {
const next = (await listKiosks()).filter((x) => x.key !== key);
await AsyncStorage.setItem(KEY, JSON.stringify(next));
return next;
}
/**
* Share a saved kiosk. Uses a `parksmarteralt://` deep link so another install
* of this app can open straight to the kiosk; falls back to human-readable text.
*/
export async function shareKiosk(k: SavedKiosk): Promise<void> {
const code = k.scannerCode ?? k.terminalSerNo ?? '';
const deepLink = `bigbrainparking://kiosk?code=${encodeURIComponent(code)}`;
const geo =
k.lat != null && k.long != null ? `\nMap: geo:${k.lat},${k.long}` : '';
await Share.share({
message:
`Parking kiosk${k.zoneName ? `: ${k.zoneName}` : ''}` +
(code ? `\nCode: ${code}` : '') +
geo +
`\nOpen in ParkSmarter: ${deepLink}`,
});
}

View file

@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react';
import * as Location from 'expo-location';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface Coords {
latitude: number;
longitude: number;
}
const LAST_LOC_KEY = 'ps_last_location';
/** Persist the most recent fix so the "near my last location" button works cold. */
async function saveLastLocation(c: Coords) {
await AsyncStorage.setItem(LAST_LOC_KEY, JSON.stringify(c));
}
export async function getLastKnownSavedLocation(): Promise<Coords | null> {
const raw = await AsyncStorage.getItem(LAST_LOC_KEY);
return raw ? (JSON.parse(raw) as Coords) : null;
}
/**
* Foreground location. On GrapheneOS this uses the OS location provider directly
* (no Google Play Services). We prefer a fast last-known fix, then refine.
*/
export function useLocation() {
const [coords, setCoords] = useState<Coords | null>(null);
const [granted, setGranted] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
const ok = status === 'granted';
setGranted(ok);
if (!ok) {
setError('Location permission denied.');
return null;
}
const last = await Location.getLastKnownPositionAsync();
if (last) {
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
setCoords(c);
void saveLastLocation(c);
}
const cur = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
setCoords(c);
void saveLastLocation(c);
return c;
} catch (e: any) {
setError(e?.message ?? 'Failed to get location.');
return null;
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { coords, granted, error, refresh };
}

View file

@ -0,0 +1,68 @@
import React from 'react';
import { ActivityIndicator, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useAuth } from '@/auth/AuthContext';
import { LoginScreen } from '@/screens/LoginScreen';
import { MapScreen } from '@/screens/MapScreen';
import { ScanScreen } from '@/screens/ScanScreen';
import { FavoritesScreen } from '@/screens/FavoritesScreen';
import { SessionsScreen } from '@/screens/SessionsScreen';
import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
import type { Zone } from 'parksmarter-client';
export type RootStackParamList = {
Tabs: undefined;
MeterDetail: { zone: Zone };
};
export type TabParamList = {
Map: undefined;
Scan: undefined;
Favorites: undefined;
Sessions: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<TabParamList>();
function Tabs() {
return (
<Tab.Navigator screenOptions={{ headerShown: true }}>
<Tab.Screen name="Map" component={MapScreen} />
<Tab.Screen name="Scan" component={ScanScreen} />
<Tab.Screen name="Favorites" component={FavoritesScreen} />
<Tab.Screen name="Sessions" component={SessionsScreen} />
</Tab.Navigator>
);
}
export function RootNavigator() {
const { status } = useAuth();
if (status === 'loading') {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<NavigationContainer>
{status === 'signedIn' ? (
<Stack.Navigator>
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
<Stack.Screen
name="MeterDetail"
component={MeterDetailScreen}
options={{ title: 'Meter' }}
/>
</Stack.Navigator>
) : (
<LoginScreen />
)}
</NavigationContainer>
);
}

View file

@ -0,0 +1,58 @@
import * as Notifications from 'expo-notifications';
/**
* Session-expiry reminders as LOCAL scheduled notifications.
*
* This is the key GrapheneOS win: because the app knows a session's end time,
* we schedule the reminder on-device no server push, no FCM, no Play Services.
* UnifiedPush (see unifiedPush.ts) is reserved for genuinely server-initiated
* events that we can't predict locally.
*/
export async function ensureNotificationPermission(): Promise<boolean> {
const settings = await Notifications.getPermissionsAsync();
if (settings.granted) return true;
const req = await Notifications.requestPermissionsAsync();
return req.granted;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export interface ScheduleReminderArgs {
transactionId: string | number;
zoneName: string;
/** When the parking session ends. */
endTime: Date;
/** Fire this many minutes before end (default 10). */
leadMinutes?: number;
}
/** Schedule an expiry reminder. Returns the notification id (for later cancel). */
export async function scheduleExpiryReminder(
args: ScheduleReminderArgs,
): Promise<string | null> {
const lead = (args.leadMinutes ?? 10) * 60 * 1000;
const fireAt = new Date(args.endTime.getTime() - lead);
if (fireAt.getTime() <= Date.now()) return null; // already too late
return Notifications.scheduleNotificationAsync({
identifier: `session-${args.transactionId}`,
content: {
title: 'Parking expiring soon',
body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
data: { transactionId: String(args.transactionId) },
},
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: fireAt },
});
}
export async function cancelExpiryReminder(transactionId: string | number): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
}

View file

@ -0,0 +1,62 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ps } from '@/api/client';
/**
* UnifiedPush integration (distributor: ntfy).
*
* IMPORTANT ARCHITECTURE NOTE
* ---------------------------
* The ParkSmarter backend only delivers push via FCM, keyed on the device token
* registered through `PUT /api/Device`. A UnifiedPush endpoint (an ntfy URL)
* cannot receive FCM directly, so server-initiated push requires a BRIDGE:
*
* ParkSmarter server --FCM--> [bridge holding an FCM token] --HTTP--> ntfy topic
* |
* UnifiedPush distributor (ntfy app)
* v
* this app
*
* Until that bridge exists, session-expiry reminders are handled entirely by
* on-device local notifications (see localReminders.ts), which covers the main
* use case without any server push. This module wires the UnifiedPush side so
* the app is ready to be a push target once the bridge (or a self-hosted relay
* that we register with `PUT /api/Device`) is in place.
*
* The `react-native-unifiedpush` API surface varies by version; treat the calls
* below as the integration point to confirm against the installed version.
*/
// eslint-disable-next-line @typescript-eslint/no-var-requires
const UnifiedPush = require('react-native-unifiedpush');
const ENDPOINT_KEY = 'ps_unifiedpush_endpoint';
const INSTANCE = 'default';
/** Kick off distributor discovery + registration (call after login). */
export async function registerUnifiedPush(): Promise<void> {
const distributors: string[] = await UnifiedPush.getDistributors();
if (!distributors.length) {
// No UnifiedPush distributor installed (e.g. ntfy). Local reminders still work.
return;
}
const saved = await UnifiedPush.getSavedDistributor?.();
const distributor = saved ?? distributors[0];
await UnifiedPush.saveDistributor(distributor);
await UnifiedPush.registerDevice(INSTANCE);
}
/**
* Handle the endpoint the distributor hands back (wire this to the library's
* `onNewEndpoint` event in the app root). We persist it and, once a bridge is
* available, register it with the backend so the server can reach us.
*/
export async function onNewEndpoint(endpoint: string): Promise<void> {
await AsyncStorage.setItem(ENDPOINT_KEY, endpoint);
// When the FCM->ntfy bridge is live, register the bridge-issued token here:
// await ps.updateDeviceToken({ pushNotificationsToken: bridgeToken, deviceType: '1' });
void ps; // referenced so the intended integration is explicit
}
export async function getSavedEndpoint(): Promise<string | null> {
return AsyncStorage.getItem(ENDPOINT_KEY);
}

View file

@ -0,0 +1,84 @@
import React, { useCallback, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import {
listKiosks,
removeKiosk,
shareKiosk,
type SavedKiosk,
} from '@/features/favorites/favoritesStore';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function FavoritesScreen() {
const navigation = useNavigation<Nav>();
const [items, setItems] = useState<SavedKiosk[]>([]);
useFocusEffect(
useCallback(() => {
void listKiosks().then(setItems);
}, []),
);
const open = async (k: SavedKiosk) => {
// Re-fetch the live zone (rates/occupancy change) before showing detail.
const res = k.scannerCode
? await ps.getMetersByScannerCode(k.scannerCode)
: k.terminalSerNo
? await ps.getMetersBySerialNumber(k.terminalSerNo)
: null;
const zone = res?.Zones?.[0];
if (zone) navigation.navigate('MeterDetail', { zone });
};
const remove = async (k: SavedKiosk) => setItems(await removeKiosk(k.key));
return (
<FlatList
contentContainerStyle={{ padding: 16 }}
data={items}
keyExtractor={(k) => k.key}
ListEmptyComponent={
<Text style={styles.empty}>
No saved kiosks yet. Scan a kiosk QR and tap Save kiosk.
</Text>
}
renderItem={({ item }) => (
<View style={styles.card}>
<TouchableOpacity style={{ flex: 1 }} onPress={() => open(item)}>
<Text style={styles.name}>{item.zoneName ?? item.key}</Text>
<Text style={styles.meta}>
{item.scannerCode ? `Code ${item.scannerCode}` : `Serial ${item.terminalSerNo}`}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.action} onPress={() => shareKiosk(item)}>
<Text style={styles.actionText}>Share</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.action} onPress={() => remove(item)}>
<Text style={[styles.actionText, { color: '#c0392b' }]}>Remove</Text>
</TouchableOpacity>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
empty: { color: '#888', textAlign: 'center', marginTop: 48 },
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f4f4f4',
borderRadius: 10,
padding: 14,
marginBottom: 10,
gap: 10,
},
name: { fontSize: 16, fontWeight: '600' },
meta: { color: '#777', fontSize: 12, marginTop: 2 },
action: { paddingHorizontal: 6, paddingVertical: 4 },
actionText: { color: '#1e6f5c', fontWeight: '600' },
});

View file

@ -0,0 +1,100 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useAuth } from '@/auth/AuthContext';
export function LoginScreen() {
const { login, error } = useAuth();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const onSubmit = async () => {
setBusy(true);
try {
await login(phone.replace(/\D/g, ''), password);
} catch {
// error surfaced via context
} finally {
setBusy(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.title}>BigBrainParking</Text>
<Text style={styles.subtitle}>Sign in with your phone number</Text>
<TextInput
style={styles.input}
placeholder="Phone number"
keyboardType="phone-pad"
autoComplete="tel"
value={phone}
onChangeText={setPhone}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TouchableOpacity
style={[styles.button, busy && styles.buttonDisabled]}
disabled={busy}
onPress={onSubmit}
>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
<View style={{ height: 12 }} />
<Text style={styles.hint}>
Forgot your password? Use the official app or a reset SMS this build reuses
the same account.
</Text>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 32, fontWeight: '700', textAlign: 'center' },
subtitle: { fontSize: 15, color: '#666', textAlign: 'center', marginBottom: 24 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 10,
padding: 14,
fontSize: 16,
marginBottom: 12,
},
error: { color: '#c0392b', marginBottom: 12 },
button: {
backgroundColor: '#1e6f5c',
borderRadius: 10,
padding: 16,
alignItems: 'center',
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
hint: { color: '#888', fontSize: 12, textAlign: 'center' },
});

View file

@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants';
import {
MapView,
Camera,
MarkerView,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { Zone } from 'parksmarter-client';
const MAP_STYLE =
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
'https://tiles.openfreemap.org/styles/liberty';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function MapScreen() {
const navigation = useNavigation<Nav>();
const { coords, refresh } = useLocation();
const [zones, setZones] = useState<Zone[]>([]);
const [loading, setLoading] = useState(false);
const [center, setCenter] = useState<Coords | null>(null);
const loadMeters = useCallback(async (c: Coords) => {
setLoading(true);
try {
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
setZones((res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null));
setCenter(c);
} catch {
setZones([]);
} finally {
setLoading(false);
}
}, []);
// Auto-load meters once we have a current fix.
useEffect(() => {
if (coords && !center) void loadMeters(coords);
}, [coords, center, loadMeters]);
const searchHere = async () => {
const c = coords ?? (await refresh());
if (c) await loadMeters(c);
};
const searchLastKnown = async () => {
const last = (await getLastKnownSavedLocation()) ?? coords;
if (last) await loadMeters(last);
};
const initial = center ?? coords;
return (
<View style={styles.container}>
{/* mapStyle / props are version-sensitive in @maplibre/maplibre-react-native */}
<MapView style={styles.map} mapStyle={MAP_STYLE}>
{initial ? (
<Camera
zoomLevel={15}
centerCoordinate={[initial.longitude, initial.latitude]}
animationDuration={0}
/>
) : null}
<UserLocation visible renderMode="native" />
{zones.map((z) => (
<MarkerView
key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)}
coordinate={[z.Long as number, z.Lat as number]}
>
<TouchableOpacity
onPress={() => navigation.navigate('MeterDetail', { zone: z })}
style={[
styles.marker,
{
backgroundColor: z.BackgroundColor ?? '#1e6f5c',
borderColor: '#ffffff',
},
]}
>
<Text style={styles.markerText} numberOfLines={1}>
{z.ZoneName ?? 'Meter'}
</Text>
</TouchableOpacity>
</MarkerView>
))}
</MapView>
<View style={styles.controls}>
<TouchableOpacity style={styles.pillButton} onPress={searchHere}>
<Text style={styles.pillText}>Search here</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pillButton} onPress={searchLastKnown}>
<Text style={styles.pillText}>Near last location</Text>
</TouchableOpacity>
</View>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator />
</View>
) : (
<View style={styles.countBadge}>
<Text style={styles.countText}>{zones.length} meters</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
marker: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
borderWidth: 2,
maxWidth: 140,
},
markerText: { color: '#fff', fontSize: 11, fontWeight: '700' },
controls: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
flexDirection: 'row',
gap: 10,
},
pillButton: {
backgroundColor: '#1e6f5c',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
pillText: { color: '#fff', fontWeight: '600' },
loading: { position: 'absolute', top: 16, right: 16 },
countBadge: {
position: 'absolute',
top: 12,
left: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
countText: { color: '#fff', fontSize: 12 },
});

View file

@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useRoute } from '@react-navigation/native';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
export function MeterDetailScreen() {
const { params } = useRoute<DetailRoute>();
const z = params.zone;
const [saved, setSaved] = useState(false);
const onSave = async () => {
await saveKiosk(toSavedKiosk(z));
setSaved(true);
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
};
const firstSpace = z.Spaces?.[0];
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: 16 }}>
<Text style={styles.title}>{z.ZoneName ?? 'Parking meter'}</Text>
{z.ZoneLocation ? <Text style={styles.sub}>{z.ZoneLocation}</Text> : null}
<View style={styles.row}>
<Field label="Scanner code" value={z.ScannerCode} />
<Field label="Serial" value={z.TerminalSerNo} />
</View>
<View style={styles.row}>
<Field label="Rate" value={z.Rate != null ? `$${z.Rate}` : undefined} />
<Field
label="Max time"
value={z.MaxTime != null ? `${z.MaxTime} min` : undefined}
/>
</View>
<View style={styles.row}>
<Field
label="Occupancy"
value={z.PercentageFull != null ? `${z.PercentageFull}% full` : undefined}
/>
<Field label="Spaces" value={z.Spaces ? String(z.Spaces.length) : undefined} />
</View>
{firstSpace?.Policies?.length ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>Rate policies</Text>
{firstSpace.Policies.slice(0, 6).map((p, i) => (
<Text key={i} style={styles.policy}>
{p.DisplayString ?? p.RateType ?? 'Policy'}
{p.Rate != null ? `$${p.Rate}` : ''}
</Text>
))}
</View>
) : null}
<TouchableOpacity
style={[styles.button, saved && styles.buttonSaved]}
onPress={onSave}
disabled={saved}
>
<Text style={styles.buttonText}>{saved ? 'Saved ✓' : 'Save kiosk'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.buttonPrimary]}
onPress={() =>
Alert.alert(
'Start session',
'Starting a paid session is a real charge — this flow (vehicle + duration + card selection) is wired to postStartParkingSession and will be enabled after review.',
)
}
>
<Text style={styles.buttonText}>Start parking session</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Field({ label, value }: { label: string; value?: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={styles.fieldValue}>{value ?? '—'}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
title: { fontSize: 24, fontWeight: '700' },
sub: { color: '#666', marginBottom: 12 },
row: { flexDirection: 'row', gap: 12, marginTop: 8 },
field: { flex: 1, backgroundColor: '#f2f2f2', borderRadius: 10, padding: 12 },
fieldLabel: { fontSize: 12, color: '#888' },
fieldValue: { fontSize: 16, fontWeight: '600', marginTop: 2 },
card: { backgroundColor: '#f7f7f7', borderRadius: 10, padding: 14, marginTop: 16 },
cardTitle: { fontWeight: '700', marginBottom: 6 },
policy: { color: '#444', marginBottom: 2 },
button: {
marginTop: 16,
backgroundColor: '#555',
borderRadius: 10,
padding: 16,
alignItems: 'center',
},
buttonPrimary: { backgroundColor: '#1e6f5c' },
buttonSaved: { backgroundColor: '#2e7d32' },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
});

View file

@ -0,0 +1,120 @@
import React, { useCallback, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
Camera,
useCameraDevice,
useCameraPermission,
useCodeScanner,
} from 'react-native-vision-camera';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
/**
* Scan a kiosk QR code (its `ScannerCode`), look the meter up via the API, and
* jump to the detail screen where it can be saved/shared.
* Uses on-device VisionCamera code scanning no Google Play Services.
*/
export function ScanScreen() {
const navigation = useNavigation<Nav>();
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
const lock = useRef(false);
React.useEffect(() => {
if (!hasPermission) void requestPermission();
}, [hasPermission, requestPermission]);
const onScanned = useCallback(
async (code: string) => {
if (lock.current) return;
lock.current = true;
setBusy(true);
try {
const res = await ps.getMetersByScannerCode(code);
const zone = res.Zones?.[0];
if (zone) navigation.navigate('MeterDetail', { zone });
} finally {
setBusy(false);
setTimeout(() => (lock.current = false), 1500);
}
},
[navigation],
);
const codeScanner = useCodeScanner({
codeTypes: ['qr', 'ean-13', 'code-128'],
onCodeScanned: (codes) => {
const value = codes[0]?.value;
if (value) void onScanned(value);
},
});
if (!hasPermission) {
return (
<View style={styles.center}>
<Text style={styles.msg}>Camera permission is needed to scan kiosks.</Text>
<TouchableOpacity style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant camera access</Text>
</TouchableOpacity>
</View>
);
}
if (!device) {
return (
<View style={styles.center}>
<Text style={styles.msg}>No camera available.</Text>
</View>
);
}
return (
<View style={styles.container}>
<Camera style={StyleSheet.absoluteFill} device={device} isActive codeScanner={codeScanner} />
<View style={styles.overlay}>
<View style={styles.reticle} />
<Text style={styles.hint}>Point at the QR code on the parking kiosk</Text>
</View>
{busy ? (
<View style={styles.busy}>
<ActivityIndicator color="#fff" />
<Text style={styles.busyText}>Looking up meter</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
msg: { fontSize: 16, textAlign: 'center', marginBottom: 16 },
overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
reticle: {
width: 220,
height: 220,
borderWidth: 3,
borderColor: '#fff',
borderRadius: 16,
},
hint: { color: '#fff', marginTop: 16, fontSize: 14 },
button: { backgroundColor: '#1e6f5c', padding: 14, borderRadius: 10 },
buttonText: { color: '#fff', fontWeight: '600' },
busy: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
},
busyText: { color: '#fff' },
});

View file

@ -0,0 +1,75 @@
import React, { useCallback, useState } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { ps } from '@/api/client';
import type { ActiveSession, PastSession } from 'parksmarter-client';
export function SessionsScreen() {
const [active, setActive] = useState<ActiveSession[]>([]);
const [past, setPast] = useState<PastSession[]>([]);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
setRefreshing(true);
try {
const [a, p] = await Promise.all([
ps.getActiveParkingSessions(),
ps.getPastParkingSessions({ currentPage: 1, pageSize: 20 }),
]);
setActive(a.ParkingSession ?? []);
setPast(p.Session ?? []);
} finally {
setRefreshing(false);
}
}, []);
useFocusEffect(
useCallback(() => {
void load();
}, [load]),
);
return (
<ScrollView
contentContainerStyle={{ padding: 16 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
>
<Text style={styles.header}>Active</Text>
{active.length === 0 ? (
<Text style={styles.empty}>No active sessions.</Text>
) : (
active.map((s, i) => (
<View key={i} style={[styles.card, styles.activeCard]}>
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text>
<Text style={styles.meta}>
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''}
</Text>
</View>
))
)}
<Text style={[styles.header, { marginTop: 20 }]}>History</Text>
{past.length === 0 ? (
<Text style={styles.empty}>No past sessions.</Text>
) : (
past.map((s, i) => (
<View key={i} style={styles.card}>
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text>
<Text style={styles.meta}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
</Text>
</View>
))
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
empty: { color: '#888', marginBottom: 8 },
card: { backgroundColor: '#f4f4f4', borderRadius: 10, padding: 14, marginBottom: 10 },
activeCard: { backgroundColor: '#e8f5e9' },
zone: { fontSize: 16, fontWeight: '600' },
meta: { color: '#777', fontSize: 13, marginTop: 2 },
});

12
app/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

98
docs/DISTRIBUTION.md Normal file
View file

@ -0,0 +1,98 @@
# Distribution: self-hosted CI → Obtainium
Goal: push a git tag, have your server build a **signed APK**, publish it as a release
on `git.mowden.top`, and have **Obtainium** on your GrapheneOS phone auto-offer updates.
Repo: `ssh://git@git.mowden.top:222/hank/BigBrainParking.git`
(web: `https://git.mowden.top/hank/BigBrainParking`)
---
## 1. One-time: create the signing key (do this locally)
The signer is **permanent** — Obtainium (and F-Droid) pin it, so if you ever rebuild with
a different key, users must uninstall + reinstall. Keep this keystore backed up and secret.
```bash
keytool -genkeypair -v -keystore bigbrainparking.keystore \
-alias bigbrainparking -keyalg RSA -keysize 2048 -validity 10000
base64 -w0 bigbrainparking.keystore # copy this string for the CI secret
```
## 2. One-time: add CI secrets
On `git.mowden.top` → the repo → **Settings → Actions → Secrets**, add:
| Secret | Value |
| --- | --- |
| `ANDROID_KEYSTORE_B64` | the base64 string from step 1 |
| `ANDROID_KEYSTORE_PASSWORD` | keystore password |
| `ANDROID_KEY_ALIAS` | `bigbrainparking` |
| `ANDROID_KEY_PASSWORD` | key password |
## 3. One-time: an Actions runner that can build Android
You need a Gitea/Forgejo **Actions runner** registered to this repo/instance. The provided
workflow (`.gitea/workflows/build-apk.yml`) runs inside a container image that already has
the Android SDK + Node, so the host runner just needs Docker:
```bash
# on your server: install act_runner (Gitea) or forgejo-runner (Forgejo), then:
./act_runner register --instance https://git.mowden.top --token <RUNNER_TOKEN>
./act_runner daemon # (or a systemd unit)
```
Make sure the runner labels include `ubuntu-latest` (or edit `runs-on:` in the workflow).
> If `git.mowden.top` is **Forgejo**, move the workflow to `.forgejo/workflows/build-apk.yml`
> (Forgejo also reads `.gitea/workflows`, so it usually works as-is).
## 4. Release flow (every update)
```bash
# bump version in app/app.json ("version") first, then:
git tag v0.1.0
git push origin v0.1.0
```
CI builds `app/android/app/build/outputs/apk/release/*.apk`, signs it, and attaches it to a
new release `v0.1.0`. Done.
## 5. Add the app in Obtainium (on the phone)
Obtainium supports Gitea/Forgejo release sources. Add app → paste:
```
https://git.mowden.top/hank/BigBrainParking
```
Pick the **Gitea** (or **Forgejo**) source type if prompted, and Obtainium will track
releases and offer each new signed APK. Install ntfy + set it as your UnifiedPush
distributor for push later.
### Fallback if your Obtainium build lacks Gitea support
Serve the APK statically and use Obtainium's **"HTML"** source:
1. CI (or a hook) copies the APK to a web dir, e.g. `/var/www/bbp/BigBrainParking-<version>.apk`.
2. nginx with autoindex on that dir:
```nginx
location /bbp/ { root /var/www; autoindex on; }
```
3. In Obtainium, add an **HTML** app pointing at `https://<yourserver>/bbp/` with an APK
link filter like `BigBrainParking-.*\.apk` and version extraction from the filename.
This path skips Gitea releases entirely — it's just a directory of signed APKs — but you
lose changelogs and per-release metadata.
---
## Notes
- **Reproducibility / F-Droid later:** if you ever want a real F-Droid repo (signed index,
usable by the F-Droid client too), run `fdroidserver` on the server pointed at the same
APK output dir. Obtainium consumes F-Droid repos as well. Overkill for one app, but an
option.
- **Version code:** Expo derives Android `versionCode` from `app.json`. Bump
`expo.version` (and optionally set `expo.android.versionCode`) each release so Obtainium
sees an increase.

8
package.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "bigbrainparking",
"private": true,
"workspaces": [
"parksmarter-client",
"app"
]
}

View file

@ -0,0 +1,7 @@
{
"phoneNumber": "5551234567",
"password": "your-parksmarter-password",
"environment": "prodv2",
"lat": 40.4406,
"lng": -79.9959
}

4
parksmarter-client/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.creds.json
capture/

View file

@ -0,0 +1,195 @@
# parksmarter-client
An unofficial, typed **TypeScript** client for the ParkSmarter (IPS Group) parking
API. It was reverse-engineered from the official Android app
`com.ipsgroupinc.parksmarter` 4.4.0 — a React Native / Hermes build — by decompiling
the JS bundle and recovering every endpoint path, header, app token, and
request/response field name.
TypeScript was chosen because the target is "an alternative Android app or web app":
the same package runs unchanged in a browser, in React Native (Expo/bare), and in
Node 18+. It has **zero runtime dependencies** (uses `fetch`). If you specifically
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
directly onto Retrofit/OkHttp — ask and it can be ported.
## Install / build
```bash
cd parksmarter-client
npm install
npm run build # emits dist/
```
Import from source (`src/index.ts`) or the built `dist/`.
## Quick start
```ts
import { ParkSmarterClient } from './src';
const ps = new ParkSmarterClient({ environment: 'prodv2' }); // default env
// 1) Bootstrap — seeds a ParkSmarter_SessionId and returns feature flags.
const validity = await ps.getApplicationValidity();
if (validity.Config?.IsInMaintenanceMode) throw new Error('maintenance');
// 2) Log in — Auth_Token + SessionId are stored automatically.
await ps.loginWithPhone({ phoneNumber: '5551234567', password: 'hunter2' });
// 3) Use authenticated endpoints.
const me = await ps.getUserDetail();
const nearby = await ps.getMetersByLocation({ latitude: 40.44, longitude: -79.99 });
// 4) Start a session.
await ps.startParkingSession({
creditCardId: me.CreditCardDetails![0].CCID!,
vehicleId: me.VehicleDetails![0].VehicleID!,
zoneId: nearby.Zones![0].ZoneId!,
spaceId: nearby.Zones![0].Spaces![0].SpaceID!,
customerId: nearby.Zones![0].CustomerID!,
meterTypeId: nearby.Zones![0].MeterTypeId!,
startTime: new Date().toISOString(),
endTime: new Date(Date.now() + 3600_000).toISOString(),
minutesToPurchase: 60,
parkingCost: 2.0,
transactionFee: 0.35,
});
```
### React Native / Node < 18
Pass a `fetchImpl` if the global `fetch` isn't present, and use persistent storage
for tokens:
```ts
import { ParkSmarterClient, TokenStore } from './src';
import * as SecureStore from 'expo-secure-store';
const tokens: TokenStore = {
getAuthToken: () => SecureStore.getItemAsync('ps_auth'),
setAuthToken: (t) =>
t ? SecureStore.setItemAsync('ps_auth', t) : SecureStore.deleteItemAsync('ps_auth'),
getSessionId: () => SecureStore.getItemAsync('ps_session'),
setSessionId: (s) =>
s ? SecureStore.setItemAsync('ps_session', s) : SecureStore.deleteItemAsync('ps_session'),
};
const ps = new ParkSmarterClient({ tokens /*, fetchImpl: fetch */ });
```
## Authentication model
This API does **not** use OAuth/Bearer. Auth is carried in custom headers:
| Header | Meaning | Source |
| --- | --- | --- |
| `Application_Token` | Identifies the app build. Required on **every** request. | Per-environment constant (baked in). |
| `X-Request-Id` | Fresh UUID per request. | Generated by the client. |
| `Auth_Token` | The logged-in user's token. | Returned in the **body** of `POST /api/Auth` as `Auth_Token`; sent as a header on authenticated calls. |
| `ParkSmarter_SessionId` | Server session id. | Seeded by `GET /api/ApplicationValidity` and login; echoed on some responses. |
| `Content-Type: application/json` | On POST/PUT only. | — |
The client persists `Auth_Token` and `SessionId` into the `TokenStore` automatically.
Every request also carries a `localeCode` query param (default `en`).
> Note: a `POST /api/Auth` returning HTTP **201** is treated by the app as a
> "needs verification / not a success" case, not a normal login. Handle 201 distinctly
> if you see it.
## Environments
| Name | Base URL | Notes |
| --- | --- | --- |
| `dev` | `https://dev-parksmarter-api.ipsmeters.com` | |
| `stage` | `https://staging-parksmarter-api.ipsmeters.com` | |
| `test` | `https://testing-parksmarter-api.ipsmeters.com` | |
| `prodv1` | `https://api.parksmarter.com` | |
| `prodv2` | `https://apiv2.parksmarter.com` | **default** |
| `prodv3` | `https://apiv3.parksmarter.com` | |
Application tokens for each are in `src/environments.ts`.
## Endpoint reference
Every method maps to one endpoint. Method is inferred from the app's naming.
| Client method | HTTP | Path |
| --- | --- | --- |
| `getApplicationValidity()` | GET | `/api/ApplicationValidity` |
| `loginWithPhone()` / `loginWithApple()` | POST | `/api/Auth` |
| `logoutAllDevices()` | POST | `/api/Auth/Logout` |
| `signUp()` | POST | `/api/User` |
| `getUserDetail()` | GET | `/api/User` |
| `updateProfile()` | PUT | `/api/User` |
| `requestDeleteUser()` | DELETE | `/api/User` |
| `isEmailRegistered()` / `isPhoneRegistered()` | GET | `/api/User` |
| `requestResetPassword()` | POST | `/api/Password` |
| `updatePassword()` | PUT | `/api/Password` |
| `requestVerifyUser()` | POST | `/api/UserVerification` |
| `verifyUser()` | GET | `/api/UserVerification` |
| `updateDeviceToken()` | PUT | `/api/Device` |
| `addVehicle()` | POST | `/api/Vehicle` |
| `updateVehicle()` | PUT | `/api/Vehicle` |
| `deleteVehicle()` | DELETE | `/api/Vehicle` |
| `addCard()` / `updateCard()` | POST | `/api/Card` |
| `setCardDefault()` | PUT | `/api/Card` |
| `deleteCard()` | DELETE | `/api/Card` |
| `getMetersByLocation()` | GET | `/api/Meter` (`Lat`,`Long`) |
| `getLimitedMetersByLocation()` | GET | `/api/MeterList` (`Lat`,`Long`) |
| `getMetersByZoneName()` / `searchMetersByZoneOrSpace()` | GET | `/api/Meter` (`ZoneName`/`Query`) |
| `getMetersBySerialNumber()` / `getMetersByScannerCode()` | GET | `/api/Meter` (`TerminalSerNo`/`ScannerCode`) |
| `getParkingLots()` | GET | `/api/ParkingLogix` |
| `getParkingEstimateMulti()` | GET | `/api/ParkingEstimateMulti` |
| `getParkingEstimateSingle()` | GET | `/api/ParkingEstimate` |
| `getParkingEstimateItems()` | GET | `/api/ParkingEstimateItems` |
| `startParkingSession()` | POST | `/api/Session` |
| `getActiveParkingSessions()` | GET | `/api/ParkingSession` |
| `getPastParkingSessions()` | GET | `/api/Session` |
| `getParkingReceipt()` | GET | `/api/ParkingReceipt` |
| `emailParkingReceipt()` | POST | `/api/ParkingReceipt` |
| `getNotificationSettings()` | GET | `/api/Setting` |
| `setNotificationSettings()` | POST | `/api/Setting` |
| `getStates()` | GET | `/api/State` |
| `getAbout()` / `getFAQ()` / `getPrivacyPolicy()` / `getTerms()` | GET | `/api/ParkSmarter*` |
## Field-name conventions
The server uses PascalCase ("PSJSON"). This client accepts friendly camelCase inputs
and maps them to the wire format for you; **responses are returned as the raw server
PascalCase JSON** and typed accordingly in `src/types.ts`. Response interfaces include
an index signature because not every optional field is guaranteed on every call — the
well-known fields are typed explicitly.
## Accuracy & verification status
Request shapes were recovered from the app code and are exact. Response models were then
**verified against production** by logging in and calling every read-only endpoint (see
`sweep.mjs`, which records field-names+types only — no PII). Each interface in
`src/types.ts` is annotated CONFIRMED or UNCONFIRMED.
- **CONFIRMED via live capture:** login (`AuthResponse`), `UserDetail`, `VehicleDetail`,
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
states wrapper, the password-reset flow, and the shared `Response` envelope.
- **UNCONFIRMED (no session history on the test account):** `ActiveSession`,
`PastSession`, `ParkingReceipt`. Field names are from static analysis; capture from an
account with at least one past/active session to confirm.
Two behaviors worth knowing (both confirmed live):
1. **Meter search requires auth.** `/api/Meter` and `/api/MeterList` return **401** without a
valid `Auth_Token`, despite being data lookups.
2. **`Response` envelope + token refresh.** Most authenticated responses embed
`Response: { Auth_Token, Message, Status }`. When `Response.Auth_Token` (or a top-level
`Auth_Token`) is non-empty, it's a rolling refresh of your user token — the client stores
it automatically.
To capture the remaining UNCONFIRMED models, re-run `sweep.mjs` on an account that has a
saved card and session history. There's **no TLS pinning**, so a proxy capture on a rooted
device/emulator is also an option if you'd rather see the app's own traffic.
## Legal
For interoperability/research with your own account. Not affiliated with or endorsed by
IPS Group / ParkSmarter. Respect their Terms of Service and applicable law.
```

30
parksmarter-client/package-lock.json generated Normal file
View file

@ -0,0 +1,30 @@
{
"name": "parksmarter-client",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "parksmarter-client",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View file

@ -0,0 +1,25 @@
{
"name": "parksmarter-client",
"version": "0.1.0",
"description": "Unofficial TypeScript client for the ParkSmarter (IPS Group) parking API. Reverse-engineered from the official Android app 4.4.0.",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src", "README.md"],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": ["parksmarter", "parking", "ips", "api-client"],
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0"
}
}

View file

@ -0,0 +1,713 @@
/**
* ParkSmarterClient a typed wrapper over the ParkSmarter (IPS Group) REST API.
*
* Reverse-engineered from the official Android app (com.ipsgroupinc.parksmarter 4.4.0,
* a React Native / Hermes build). All endpoint paths, header names, the app-token
* scheme, and request/response field names were recovered from the app bundle.
*
* Auth model (NOT OAuth bearer):
* - `Application_Token` : identifies the app build; required on every call.
* - `Auth_Token` : the logged-in user's token; returned by POST /api/Auth
* (login) in the body field `Auth_Token`, then sent as a
* header on authenticated calls.
* - `ParkSmarter_SessionId` : a server session id; seeded by
* GET /api/ApplicationValidity and login, echoed on some
* responses. Sent as a header when present.
* - `X-Request-Id` : a fresh UUID per request.
*
* The client persists Auth_Token and SessionId into the provided TokenStore
* automatically after login / bootstrap.
*/
import {
DEFAULT_ENVIRONMENT,
ENVIRONMENTS,
Environment,
EnvironmentName,
} from './environments.js';
import {
HttpClient,
MemoryTokenStore,
ParkSmarterResponse,
TokenStore,
} from './http.js';
import * as T from './types.js';
export interface ParkSmarterClientOptions {
/** Named environment (default 'prodv2') or a fully custom Environment. */
environment?: EnvironmentName | Environment;
/** Token persistence. Defaults to in-memory. Use secure storage in real apps. */
tokens?: TokenStore;
/** Locale sent as the `localeCode` query param (default 'en'). */
localeCode?: string;
timeoutMs?: number;
/** Provide a fetch impl for React Native / Node < 18 / tests. */
fetchImpl?: typeof fetch;
uuid?: () => string;
}
function resolveEnvironment(
env: EnvironmentName | Environment | undefined,
): Environment {
if (!env) return ENVIRONMENTS[DEFAULT_ENVIRONMENT];
if (typeof env === 'string') return ENVIRONMENTS[env];
return env;
}
export class ParkSmarterClient {
readonly http: HttpClient;
readonly tokens: TokenStore;
constructor(options: ParkSmarterClientOptions = {}) {
this.tokens = options.tokens ?? new MemoryTokenStore();
this.http = new HttpClient({
environment: resolveEnvironment(options.environment),
tokens: this.tokens,
localeCode: options.localeCode,
timeoutMs: options.timeoutMs,
fetchImpl: options.fetchImpl,
uuid: options.uuid,
});
}
/** Set the locale used for the `localeCode` query param on subsequent calls. */
setLocale(localeCode: string): void {
this.http.setLocaleCode(localeCode);
}
private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
return p.then((r) => r.data);
}
/* ============================================================== */
/* Bootstrap / app validity */
/* ============================================================== */
/**
* GET /api/ApplicationValidity call this first. Returns feature flags,
* maintenance/upgrade status, and a SessionId that is stored automatically.
* Public (no Auth_Token required).
*/
async getApplicationValidity(): Promise<T.ApplicationValidityResponse> {
const res = await this.http.request<T.ApplicationValidityResponse>({
method: 'GET',
path: '/api/ApplicationValidity',
query: { ApplicationToken: this.http.environment.appToken },
includeAuthToken: false,
});
return res.data;
}
/* ============================================================== */
/* Auth */
/* ============================================================== */
/** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
method: 'POST',
path: '/api/Auth',
body: { UserName: params.phoneNumber, Password: params.password },
includeAuthToken: false,
});
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
return res.data;
}
/** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
async loginWithApple(params: T.LoginWithAppleParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
method: 'POST',
path: '/api/Auth',
body: {
EmailAddress: params.emailAddress,
AppleUserId: params.appleId,
ProviderAuth: params.providerAuth,
ProviderIdentity: params.providerIdentity,
},
includeAuthToken: false,
});
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
return res.data;
}
/** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */
async loginWithCachedToken(authToken: string): Promise<void> {
await this.tokens.setAuthToken(authToken);
}
/** POST /api/Auth/Logout — invalidate the token on all devices. */
async logoutAllDevices(): Promise<void> {
await this.http.request({ method: 'POST', path: '/api/Auth/Logout' });
}
/** Clear local tokens (client-side sign out). */
async logoutLocal(): Promise<void> {
await this.tokens.setAuthToken(null);
await this.tokens.setSessionId(null);
}
/* ============================================================== */
/* Sign up, password, verification */
/* ============================================================== */
/** POST /api/User — create an account. Public. */
async signUp(params: T.SignUpParams): Promise<boolean> {
await this.http.request({
method: 'POST',
path: '/api/User',
body: {
PersonalEmailAddress: params.emailAddress,
PersonalPhone: params.mobilePhone,
Password: params.password,
},
includeAuthToken: false,
});
return true;
}
/** POST /api/Password — request a password reset. Public. */
async requestResetPassword(
params: T.RequestResetPasswordParams,
): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/Password',
body: {
ResetPasswordType: params.reqType,
PersonalEmailAddress: '',
PersonalPhone: params.phoneNumber,
},
includeAuthToken: false,
});
}
/** PUT /api/Password — change password (authenticated). */
async updatePassword(params: T.UpdatePasswordParams): Promise<void> {
await this.http.request({
method: 'PUT',
path: '/api/Password',
body: { OldPassword: params.oldPassword, NewPassword: params.newPassword },
});
}
/** POST /api/UserVerification — request an SMS verification code. */
async requestVerifyUser(params: T.RequestVerifyUserParams): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/UserVerification',
body: { PhoneNumber: '1' + params.phoneNumber },
});
}
/** GET /api/UserVerification — confirm an SMS code. */
async verifyUser(params: T.VerifyUserParams): Promise<void> {
await this.http.request({
method: 'GET',
path: '/api/UserVerification',
query: {
PhoneNumber: '1' + params.phoneNumber,
ConfirmationCode: params.code,
},
});
}
/** GET /api/User?EmailAddress=… — is this email already registered? */
async isEmailRegistered(emailAddress: string): Promise<boolean> {
const res = await this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { EmailAddress: emailAddress },
includeAuthToken: false,
});
return Boolean(res.data?.PersonalEmailAddress);
}
/** GET /api/User?PhoneNumber=… — is this phone already registered? */
async isPhoneRegistered(phoneNumber: string): Promise<boolean> {
const res = await this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { PhoneNumber: phoneNumber },
includeAuthToken: false,
});
return Boolean(res.data?.PersonalPhone);
}
/* ============================================================== */
/* User profile */
/* ============================================================== */
/** GET /api/User — full profile incl. vehicles & cards (authenticated). */
getUserDetail(): Promise<T.UserDetail> {
return this.unwrap(
this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { EmailAddress: '', PhoneNumber: '' },
}),
);
}
/** PUT /api/User — update email/phone (authenticated). */
async updateProfile(params: T.UpdateProfileParams): Promise<void> {
await this.http.request({
method: 'PUT',
path: '/api/User',
body: {
PersonalEmailAddress: params.emailAddress,
PersonalPhone: params.phoneNumber,
},
});
}
/** DELETE /api/User — request account deletion (authenticated). */
async requestDeleteUser(): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/User',
query: { DeviceType: 'Android' },
});
}
/** PUT /api/Device — register/update the push device token. */
async updateDeviceToken(
params: T.UpdateDeviceTokenParams,
): Promise<T.UpdateDeviceTokenResponse> {
return this.unwrap(
this.http.request<T.UpdateDeviceTokenResponse>({
method: 'PUT',
path: '/api/Device',
body: {
DeviceID: params.pushNotificationsToken,
IMEINumber: params.imeiNumber,
DeviceType: params.deviceType ?? '1',
Language: params.language,
},
}),
);
}
/* ============================================================== */
/* Vehicles */
/* ============================================================== */
/** POST /api/Vehicle — add a vehicle. */
async addVehicle(params: T.AddVehicleParams): Promise<T.VehicleMutationResponse> {
return this.unwrap(
this.http.request<T.VehicleMutationResponse>({
method: 'POST',
path: '/api/Vehicle',
body: {
VehiclePlate: params.plate,
VehicleState: params.state,
VehicleAlias: params.vehicleAlias,
IsDefault: params.isDefaultVehicle ?? false,
},
}),
);
}
/** PUT /api/Vehicle — update a vehicle. Success when `Status === 'Success'`. */
async updateVehicle(
params: T.UpdateVehicleParams,
): Promise<T.VehicleMutationResponse> {
return this.unwrap(
this.http.request<T.VehicleMutationResponse>({
method: 'PUT',
path: '/api/Vehicle',
body: {
VehiclePlate: params.plate,
VehicleState: params.state,
VehicleID: params.id,
VehicleAlias: params.vehicleAlias,
IsDefault: params.isDefaultVehicle ?? false,
},
}),
);
}
/** DELETE /api/Vehicle?VehicleID=… */
async deleteVehicle(vehicleId: number | string): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/Vehicle',
query: { VehicleID: vehicleId },
});
}
/* ============================================================== */
/* Credit cards */
/* ============================================================== */
/** POST /api/Card — add a card. */
async addCard(params: T.AddCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'POST',
path: '/api/Card',
body: this.buildCardBody(params),
}),
);
}
/** POST /api/Card — update a card (full replace). */
async updateCard(params: T.AddCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'POST',
path: '/api/Card',
body: this.buildCardBody(params),
}),
);
}
/** PUT /api/Card — set (or unset) a card as default. */
async setCardDefault(params: T.SetDefaultCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'PUT',
path: '/api/Card',
body: { CCID: params.id, CCDefault: params.isDefaultCard },
}),
);
}
/** DELETE /api/Card?CCID=… */
async deleteCard(cardId: number | string): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/Card',
query: { CCID: cardId },
});
}
private buildCardBody(p: T.AddCardParams) {
return {
CCNumber: p.cardNumber,
CCAlias: p.alias,
CCExpDate: p.expDate,
CCZip: p.zipCode,
CCDefault: p.isDefaultCard ?? false,
ParentCCID: p.id,
};
}
/* ============================================================== */
/* Meters / zones */
/* ============================================================== */
/** GET /api/Meter?Lat=&Long= — meters near a coordinate. */
getMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { Lat: loc.latitude, Long: loc.longitude },
}),
);
}
/** GET /api/MeterList?Lat=&Long= — limited meter list near a coordinate. */
getLimitedMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/MeterList',
query: { Lat: loc.latitude, Long: loc.longitude },
}),
);
}
/** GET /api/Meter?ZoneName=… */
getMetersByZoneName(zoneName: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { ZoneName: zoneName },
}),
);
}
/** GET /api/Meter?Query=… — zone or space name search. */
searchMetersByZoneOrSpace(query: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { Query: query },
}),
);
}
/** GET /api/Meter?TerminalSerNo=… */
getMetersBySerialNumber(serialNumber: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { TerminalSerNo: serialNumber },
}),
);
}
/** GET /api/Meter?ScannerCode=… (e.g. from a scanned QR/barcode). */
getMetersByScannerCode(scannerCode: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { ScannerCode: scannerCode },
}),
);
}
/** GET /api/ParkingLogix — nearby parking lots with occupancy. */
getParkingLots(): Promise<T.ParkingLotsResponse> {
return this.unwrap(
this.http.request<T.ParkingLotsResponse>({
method: 'GET',
path: '/api/ParkingLogix',
}),
);
}
/* ============================================================== */
/* Estimates */
/* ============================================================== */
/** GET /api/ParkingEstimateMulti — multi-duration price ladder. */
getParkingEstimateMulti(
params: T.MultiEstimateParams,
): Promise<T.MultiEstimateResponse> {
const query: Record<string, unknown> = {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
MinCreditAmount: params.minCreditAmount,
VehicleID: params.vehicleId,
};
if (params.bleEncBytes) query.BleEncBytesString = params.bleEncBytes;
return this.unwrap(
this.http.request<T.MultiEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimateMulti',
query,
}),
);
}
/** GET /api/ParkingEstimate — single-duration price. */
getParkingEstimateSingle(
params: T.SingleEstimateParams,
): Promise<T.SingleEstimateResponse> {
return this.unwrap(
this.http.request<T.SingleEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimate',
query: {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
ParkingDuration: params.durationInMinutes,
VehicleID: params.vehicleId,
CCID: params.creditCardId,
},
}),
);
}
/** GET /api/ParkingEstimateItems — item-based price options. */
getParkingEstimateItems(
params: T.ItemsEstimateParams,
): Promise<T.ItemsEstimateResponse> {
return this.unwrap(
this.http.request<T.ItemsEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimateItems',
query: {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
VehicleID: params.vehicleId,
},
}),
);
}
/* ============================================================== */
/* Parking sessions */
/* ============================================================== */
/** POST /api/Session — start/pay for a parking session. */
startParkingSession(
params: T.StartParkingSessionParams,
): Promise<T.StartParkingSessionResponse> {
const fee = params.transactionFee ?? 0;
const body: Record<string, unknown> = {
CCID: String(params.creditCardId),
Amount: (params.parkingCost + fee).toFixed(2),
SpaceID: params.spaceId,
StartTime: params.startTime,
EndTime: params.endTime,
CustomerID: params.customerId,
VehicleID: String(params.vehicleId),
TimePurchased: String(params.minutesToPurchase),
ParkingCost: params.parkingCost.toFixed(2),
TransactionFee: fee.toFixed(2),
ZoneID: params.zoneId,
MinCreditAmount:
params.minCreditAmount != null
? params.minCreditAmount.toFixed(2)
: undefined,
MeterTypeId: params.meterTypeId,
};
if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes;
return this.unwrap(
this.http.request<T.StartParkingSessionResponse>({
method: 'POST',
path: '/api/Session',
body,
}),
);
}
/** GET /api/ParkingSession — currently active sessions. */
getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> {
return this.unwrap(
this.http.request<T.ActiveSessionsResponse>({
method: 'GET',
path: '/api/ParkingSession',
}),
);
}
/** GET /api/Session — past sessions (paged). */
getPastParkingSessions(
params: T.PastSessionsParams = {},
): Promise<T.PastSessionsResponse> {
return this.unwrap(
this.http.request<T.PastSessionsResponse>({
method: 'GET',
path: '/api/Session',
query: {
CurrentPage: params.currentPage ?? '',
PageSize: params.pageSize ?? '',
},
}),
);
}
/* ============================================================== */
/* Receipts */
/* ============================================================== */
/** GET /api/ParkingReceipt?TransactionID=… */
getParkingReceipt(transactionId: number | string): Promise<T.ReceiptResponse> {
return this.unwrap(
this.http.request<T.ReceiptResponse>({
method: 'GET',
path: '/api/ParkingReceipt',
query: { TransactionID: transactionId },
}),
);
}
/** POST /api/ParkingReceipt?Id=… — email a receipt to the account holder. */
async emailParkingReceipt(receiptId: number | string): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/ParkingReceipt',
query: { Id: receiptId },
});
}
/* ============================================================== */
/* Notification settings */
/* ============================================================== */
/** GET /api/Setting — raw notification settings bitmask. */
getNotificationSettings(): Promise<T.RawSettingsResponse> {
return this.unwrap(
this.http.request<T.RawSettingsResponse>({
method: 'GET',
path: '/api/Setting',
}),
);
}
/**
* POST /api/Setting update notification settings.
* `userSettings` is the numeric bitmask (as a string) the app computes via reduceSettings.
*/
async setNotificationSettings(userSettings: string | number): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/Setting',
body: { UserSettings: String(userSettings) },
});
}
/* ============================================================== */
/* Static content */
/* ============================================================== */
/**
* GET /api/State?ID= states/regions under a parent id.
* The `ID` param is required the route 404s without it (confirmed against prod).
*/
getStates(id: number | string): Promise<T.StatesResponse> {
return this.unwrap(
this.http.request<T.StatesResponse>({
method: 'GET',
path: '/api/State',
query: { ID: id },
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterAbout — `{ Value }`. */
getAbout(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterAbout',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterFAQ — `{ FAQs: [...] }`. */
getFAQ(): Promise<T.FAQResponse> {
return this.unwrap(
this.http.request<T.FAQResponse>({
method: 'GET',
path: '/api/ParkSmarterFAQ',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterPrivacyPolicies — `{ Value }`. */
getPrivacyPolicy(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterPrivacyPolicies',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterTerms — `{ Value }`. */
getTerms(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterTerms',
includeAuthToken: false,
}),
);
}
}

View file

@ -0,0 +1,58 @@
/**
* ParkSmarter backend environments.
*
* Recovered from the official app (com.ipsgroupinc.parksmarter 4.4.0).
* Each environment pairs a base URL with an application token that the server
* requires on every request via the `Application_Token` header.
*
* `prodv2` is the environment the shipping app defaults to.
*/
export type EnvironmentName =
| 'dev'
| 'stage'
| 'test'
| 'prodv1'
| 'prodv2'
| 'prodv3';
export interface Environment {
name: EnvironmentName;
baseUrl: string;
appToken: string;
}
export const ENVIRONMENTS: Record<EnvironmentName, Environment> = {
dev: {
name: 'dev',
baseUrl: 'https://dev-parksmarter-api.ipsmeters.com',
appToken: 'ED09B2F6-BE30-4C92-9204-7D02026CAAE7',
},
stage: {
name: 'stage',
baseUrl: 'https://staging-parksmarter-api.ipsmeters.com',
appToken: 'AC657B81-121E-42E8-B6BF-5C2C668B4E00',
},
test: {
name: 'test',
baseUrl: 'https://testing-parksmarter-api.ipsmeters.com',
appToken: 'AC657B81-121E-42E8-B6BF-5C2C668B4E00',
},
prodv1: {
name: 'prodv1',
baseUrl: 'https://api.parksmarter.com',
appToken: '98774898-E21E-4548-B513-FA7211ABA442',
},
prodv2: {
name: 'prodv2',
baseUrl: 'https://apiv2.parksmarter.com',
appToken: 'B66EEDDA-B618-4926-B8A0-F5B58397EEBA',
},
prodv3: {
name: 'prodv3',
baseUrl: 'https://apiv3.parksmarter.com',
appToken: '98774898-E21E-4548-B513-FA7211ABA442',
},
};
/** The environment the official app ships pointed at. */
export const DEFAULT_ENVIRONMENT: EnvironmentName = 'prodv2';

View file

@ -0,0 +1,252 @@
/**
* Transport layer for the ParkSmarter API.
*
* This mirrors the request pipeline used by the official app:
* - Base URL + query string are concatenated onto the endpoint path.
* - Query params are serialized as `?k=encodeURIComponent(v)&...`.
* - Headers:
* Application_Token always (identifies the app build)
* X-Request-Id always (a fresh UUID per request)
* Content-Type: application/json on POST/PUT
* Auth_Token when the endpoint requires an authenticated user
* ParkSmarter_SessionId when a server session id is available
* - The user auth token and session id are NOT HTTP bearer tokens; they are
* custom headers named exactly `Auth_Token` and `ParkSmarter_SessionId`.
*/
import { Environment } from './environments.js';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface TokenStore {
/** Current user auth token, or null if signed out. */
getAuthToken(): string | null | Promise<string | null>;
setAuthToken(token: string | null): void | Promise<void>;
/** Current server session id, or null. */
getSessionId(): string | null | Promise<string | null>;
setSessionId(sessionId: string | null): void | Promise<void>;
}
/** Simple in-memory token store. Swap for expo-secure-store / localStorage in real apps. */
export class MemoryTokenStore implements TokenStore {
private authToken: string | null = null;
private sessionId: string | null = null;
getAuthToken() {
return this.authToken;
}
setAuthToken(token: string | null) {
this.authToken = token;
}
getSessionId() {
return this.sessionId;
}
setSessionId(sessionId: string | null) {
this.sessionId = sessionId;
}
}
export interface RequestOptions {
method: HttpMethod;
/** Endpoint path, e.g. `/api/Auth`. */
path: string;
query?: Record<string, unknown> | undefined;
body?: unknown;
/** Send the `Auth_Token` header (default: true except for auth/public endpoints). */
includeAuthToken?: boolean;
/** Send the `ParkSmarter_SessionId` header when available (default: true). */
includeSessionId?: boolean;
/** Abort signal (also drives the per-request timeout). */
signal?: AbortSignal;
}
export interface ParkSmarterResponse<T> {
status: number;
data: T;
headers: Headers;
requestId: string;
}
/** Thrown for non-2xx responses. `body` is the parsed server payload when JSON. */
export class ParkSmarterApiError extends Error {
status: number;
body: unknown;
requestId: string;
/** Server-provided message when present (server uses PascalCase `Message`). */
serverMessage?: string;
constructor(status: number, body: unknown, requestId: string) {
const serverMessage =
body && typeof body === 'object'
? (body as Record<string, unknown>).Message ??
(body as Record<string, unknown>).message
: undefined;
super(
`ParkSmarter API error ${status}` +
(serverMessage ? `: ${serverMessage}` : ''),
);
this.name = 'ParkSmarterApiError';
this.status = status;
this.body = body;
this.requestId = requestId;
if (typeof serverMessage === 'string') this.serverMessage = serverMessage;
}
}
export interface HttpClientConfig {
environment: Environment;
tokens: TokenStore;
/** BCP-47-ish locale code sent as the `localeCode` query param (default: 'en'). */
localeCode?: string;
/** Per-request timeout in ms (default: 30000). */
timeoutMs?: number;
/** Override fetch (e.g. for React Native or tests). Defaults to global fetch. */
fetchImpl?: typeof fetch;
/** Override UUID generation. Defaults to crypto.randomUUID when available. */
uuid?: () => string;
}
function defaultUuid(): string {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
// RFC4122-ish fallback (non-crypto) for older runtimes.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function serializeQuery(query: Record<string, unknown>): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
export class HttpClient {
private cfg: Required<Omit<HttpClientConfig, 'environment' | 'tokens'>> &
Pick<HttpClientConfig, 'environment' | 'tokens'>;
constructor(config: HttpClientConfig) {
this.cfg = {
environment: config.environment,
tokens: config.tokens,
localeCode: config.localeCode ?? 'en',
timeoutMs: config.timeoutMs ?? 30000,
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
uuid: config.uuid ?? defaultUuid,
};
if (!this.cfg.fetchImpl) {
throw new Error(
'No fetch implementation available. Pass config.fetchImpl (e.g. node-fetch, or a polyfill).',
);
}
}
get environment(): Environment {
return this.cfg.environment;
}
get tokens(): TokenStore {
return this.cfg.tokens;
}
setLocaleCode(localeCode: string) {
this.cfg.localeCode = localeCode;
}
async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
const { method } = opts;
const requestId = this.cfg.uuid();
// The app appends `localeCode` to the query params of essentially every call.
const query: Record<string, unknown> = {
localeCode: this.cfg.localeCode,
...(opts.query ?? {}),
};
const url =
this.cfg.environment.baseUrl + opts.path + serializeQuery(query);
const headers: Record<string, string> = {
Application_Token: this.cfg.environment.appToken,
'X-Request-Id': requestId,
Accept: 'application/json',
};
if (method === 'POST' || method === 'PUT') {
headers['Content-Type'] = 'application/json';
}
const includeAuthToken = opts.includeAuthToken ?? true;
const includeSessionId = opts.includeSessionId ?? true;
if (includeAuthToken) {
const authToken = await this.cfg.tokens.getAuthToken();
if (authToken) headers['Auth_Token'] = authToken;
}
if (includeSessionId) {
const sessionId = await this.cfg.tokens.getSessionId();
if (sessionId) headers['ParkSmarter_SessionId'] = sessionId;
}
// Timeout wired through an AbortController, honoring any caller-supplied signal.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
if (opts.signal) {
if (opts.signal.aborted) controller.abort();
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
let res: Response;
try {
res = await this.cfg.fetchImpl(url, {
method,
headers,
body:
opts.body !== undefined && opts.body !== null
? JSON.stringify(opts.body)
: undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
const text = await res.text();
let data: unknown = undefined;
if (text && text.trim().length) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
// Keep the token store fresh from what the server echoes back:
// - SessionId is returned on bootstrap/login.
// - A fresh Auth_Token may be returned either at the top level (login) or
// inside the common `Response: { Auth_Token, Message, Status }` envelope
// (a rolling token-refresh mechanism). Only overwrite on a non-empty value.
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
const envelope = obj.Response as Record<string, unknown> | undefined;
const sessionId = obj.SessionId;
if (typeof sessionId === 'string' && sessionId) {
await this.cfg.tokens.setSessionId(sessionId);
}
const refreshed =
(typeof obj.Auth_Token === 'string' && obj.Auth_Token) ||
(envelope && typeof envelope.Auth_Token === 'string' && envelope.Auth_Token);
if (refreshed) {
await this.cfg.tokens.setAuthToken(refreshed);
}
}
if (!res.ok) {
throw new ParkSmarterApiError(res.status, data ?? text, requestId);
}
return { status: res.status, data: data as T, headers: res.headers, requestId };
}
}

View file

@ -0,0 +1,20 @@
export { ParkSmarterClient } from './client.js';
export type { ParkSmarterClientOptions } from './client.js';
export {
ENVIRONMENTS,
DEFAULT_ENVIRONMENT,
} from './environments.js';
export type { Environment, EnvironmentName } from './environments.js';
export {
HttpClient,
MemoryTokenStore,
ParkSmarterApiError,
} from './http.js';
export type {
TokenStore,
RequestOptions,
ParkSmarterResponse,
HttpMethod,
HttpClientConfig,
} from './http.js';
export * from './types.js';

View file

@ -0,0 +1,624 @@
/**
* ParkSmarter API data models.
*
* Two casing worlds exist:
* - This client's *inputs* use friendly camelCase (see the `*Params` types).
* - The server speaks PascalCase ("PSJSON"). Response models below therefore use
* the exact server field spellings recovered from the app. Response interfaces
* carry an index signature because not every field is guaranteed on every call;
* the well-known fields are typed explicitly. Run a live capture to lock down
* the optional tail for a given endpoint if you need exhaustiveness.
*/
/**
* The common status/token-refresh envelope embedded in most authenticated
* responses as `Response`. `Auth_Token` here is usually null but, when present,
* is a rolling refresh of the user token (the client stores it automatically).
* CONFIRMED via live capture.
*/
export interface ResponseEnvelope {
Auth_Token: string | null;
Message: string | null;
Status: string | null;
}
/**
* Casing note (CONFIRMED via live capture): the meter/zone *response* uses
* `ZoneId` / `SpaceId` / `CustomerId` (lowercase "d"), while estimate & session
* *requests* expect `ZoneID` / `SpaceID` / `CustomerID` (capital "ID"). When you
* feed a captured Zone into an estimate/session, read `zone.CustomerId` /
* `space.SpaceId` and pass them as the client's camelCase params the client
* emits the capital-ID request spelling for you.
*/
/* ------------------------------------------------------------------ */
/* Auth & account */
/* ------------------------------------------------------------------ */
export interface LoginWithPhoneParams {
/** Phone number, digits only (no country code prefix). */
phoneNumber: string;
password: string;
}
export interface LoginWithAppleParams {
emailAddress: string;
appleId: string;
providerAuth: string;
providerIdentity: string;
}
/**
* POST /api/Auth response (login). CONFIRMED: `{ Status, Message, Auth_Token }`.
* Auth_Token is persisted automatically; SessionId is delivered separately (by
* bootstrap / headers) and also persisted.
*/
export interface AuthResponse {
Status?: string;
Message?: string;
Auth_Token?: string;
[key: string]: unknown;
}
export interface SignUpParams {
emailAddress: string;
/** Phone number, digits only. */
mobilePhone: string;
password: string;
}
export interface RequestResetPasswordParams {
/** Server `ResetPasswordType`; the app passes a numeric/string reason code. */
reqType: string | number;
/** Phone number, digits only. */
phoneNumber: string;
}
export interface UpdatePasswordParams {
oldPassword: string;
newPassword: string;
}
export interface UpdateProfileParams {
emailAddress: string;
/** Phone number, digits only. */
phoneNumber: string;
}
export interface RequestVerifyUserParams {
/** Phone number, digits only. The client prefixes country code "1". */
phoneNumber: string;
}
export interface VerifyUserParams {
/** Phone number, digits only. The client prefixes country code "1". */
phoneNumber: string;
/** SMS confirmation code. */
code: string;
}
export interface UpdateDeviceTokenParams {
/** Push token (server field `DeviceID`). */
pushNotificationsToken: string;
imeiNumber?: string;
/** Defaults to '1' (Android) as the app hardcodes. */
deviceType?: string;
/** BCP-47 language, defaults to the client locale. */
language?: string;
}
export interface UpdateDeviceTokenResponse {
Status?: string;
Message?: string;
[key: string]: unknown;
}
/** GET /api/User response. CONFIRMED via live capture. */
export interface UserDetail {
PersonalEmailAddress?: string;
/** Digits-only phone as a string. */
PersonalPhone?: string;
VehicleDetails?: VehicleDetail[];
CreditCardDetails?: CreditCardDetail[];
/** Numeric flag (0/1), not a boolean. */
OffersOptIn?: number;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Vehicles */
/* ------------------------------------------------------------------ */
/** CONFIRMED via live capture. Note `IsDefault` is a string ("true"/"false"), not a boolean. */
export interface VehicleDetail {
VehicleID?: number;
VehiclePlate?: string;
VehicleState?: string;
VehicleAlias?: string;
/** Server returns this as a string, e.g. "true" / "false". */
IsDefault?: string;
/** Per-item status fields, usually null on reads. */
Status?: string | null;
Message?: string | null;
[key: string]: unknown;
}
export interface AddVehicleParams {
plate: string;
state: string;
vehicleAlias?: string;
isDefaultVehicle?: boolean;
}
export interface UpdateVehicleParams extends AddVehicleParams {
id: number | string;
}
export interface VehicleMutationResponse {
Status?: string;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Credit cards */
/* ------------------------------------------------------------------ */
/**
* CONFIRMED via live capture. The PAN is never returned only `CCFirstSix` +
* `CCLastFour`. `CCDefault` comes back as a string ("true"/"false"). Note the
* response uses `ParentPBPCardId`, whereas the add/update *request* uses `ParentCCID`.
*/
export interface CreditCardDetail {
CCID?: number;
CCAlias?: string;
/** First 6 digits (BIN), numeric string. */
CCFirstSix?: string;
/** Last 4 digits, numeric string. */
CCLastFour?: string;
CCExpDate?: string;
CCZip?: string;
/** String "true"/"false", not a boolean. */
CCDefault?: string;
ParentPBPCardId?: number | null;
/** Per-item status fields, usually null on reads. */
Status?: string | null;
Message?: string | null;
[key: string]: unknown;
}
export interface AddCardParams {
cardNumber: string;
alias?: string;
/** Expiry, format as the app sends it (e.g. "MM/YY"). */
expDate: string;
zipCode: string;
isDefaultCard?: boolean;
/** Present when editing an existing card (server `ParentCCID`). */
id?: number | string;
}
export interface SetDefaultCardParams {
id: number | string;
isDefaultCard: boolean;
}
/* ------------------------------------------------------------------ */
/* Meters / zones */
/* ------------------------------------------------------------------ */
export interface LatLng {
latitude: number;
longitude: number;
}
/** A rate/time policy slot within a space. CONFIRMED via live capture. */
export interface SpacePolicy {
CurrentSlot?: boolean;
DayNumber?: number;
DisplayString?: string;
StartDateTime?: string;
EndDateTime?: string;
StartTimeDisplay?: string;
EndTimeDisplay?: string;
EventRateFlag?: boolean;
MaxTime?: number;
MessageHeader?: string;
MessageText?: string;
Rate?: number;
RateType?: string;
[key: string]: unknown;
}
/**
* A single space within a zone. CONFIRMED via live capture.
* Note the id is `SpaceId` (lowercase "d") here; estimate/session requests want
* the capital-ID `SpaceID` (the client maps camelCase params to that spelling).
*/
export interface Space {
SpaceId?: number;
SpaceName?: string;
SSPMSpaceId?: number;
MeterPurchaseType?: number;
OccupancyStatus?: number;
Policies?: SpacePolicy[];
[key: string]: unknown;
}
/**
* A parking zone/meter as returned inside `Zones`. CONFIRMED via live capture.
* `ScannerCode` is the value encoded in kiosk QR codes; `TerminalSerNo` is the
* printed serial. Both drive the scan / lookup features.
*/
export interface Zone {
ZoneId?: number;
ZoneGuid?: string;
ZoneName?: string;
ZoneLocation?: string;
ZonePlates?: unknown;
/** Printed serial number (numeric string). */
TerminalSerNo?: string;
/** QR-encoded scanner code. */
ScannerCode?: string;
Lat?: number;
Long?: number;
CustomerId?: number;
CustomerName?: string;
/** Minutes offset / IANA-less numeric zone id as returned. */
TimeZone?: number;
DSTAdjust?: number;
CityLogo?: string;
DepartmentLogo?: string;
BackgroundColor?: string;
/** Returned as a numeric string. */
ForegroundColor?: string;
MinimumAmount?: number;
MaxTime?: number;
MeterType?: string;
MeterTypeId?: number;
/** Base rate, returned as a string. */
Rate?: string;
ProgressiveRate?: number;
PercentageFull?: number;
IsAllowOverAir?: boolean;
IsAllowBLE?: boolean;
IsExtension?: boolean;
IsPaid?: boolean;
Spaces?: Space[];
[key: string]: unknown;
}
/** GET /api/Meter and /api/MeterList. CONFIRMED via live capture. */
export interface MetersResponse {
Zones?: Zone[] | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Parking lots (ParkingLogix) */
/* ------------------------------------------------------------------ */
/** One level/section within a ParkingLogix lot. CONFIRMED via live capture. */
export interface ParkingLotDetail {
ParkingLogixLotDetailId?: number;
ParkingLogixLotId?: number;
Level?: number;
TotalSpaces?: number;
FreeSpaces?: number;
[key: string]: unknown;
}
/** CONFIRMED via live capture. Per-level occupancy lives in ParkingLogixLotDetails. */
export interface ParkingLot {
ParkingLogixLotId?: number;
LotName?: string;
LocationAddress?: string;
Lat?: number;
Long?: number;
TotalSpaces?: number;
DefaultFree?: boolean;
ParkingLogixLotDetails?: ParkingLotDetail[];
[key: string]: unknown;
}
export interface ParkingLotsResponse {
ParkingLogixLots?: ParkingLot[] | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Estimates */
/* ------------------------------------------------------------------ */
export interface MultiEstimateParams {
zoneId: number | string;
spaceId: number | string;
customerId: number | string;
/** Zone minimum credit (server `MinCreditAmount`). */
minCreditAmount?: number | string;
vehicleId: number | string;
/** Optional BLE encrypted bytes string. */
bleEncBytes?: string;
}
export interface SingleEstimateParams {
zoneId: number | string;
spaceId: number | string;
customerId: number | string;
/** Server `ParkingDuration`, in minutes. */
durationInMinutes: number;
vehicleId: number | string;
/** Server `CCID` — credit card id. */
creditCardId: number | string;
}
export interface ItemsEstimateParams {
zoneId: number | string;
spaceId: number | string;
customerId: number | string;
vehicleId: number | string;
}
/**
* One priced duration option. CONFIRMED via live capture.
* Money/id fields come back as numeric *strings* here.
*/
export interface ParkingDetail {
Minutes?: number;
StartTime?: string;
EndTime?: string;
ParkingCost?: string;
TransactionFee?: string;
CurrentAmountPaid?: number;
CustomerID?: string;
CustomerName?: string;
Zone?: string;
ZoneID?: string;
Space?: string;
SpaceID?: string;
VehicleID?: string;
VehicleNumber?: string | null;
CCNumber?: string | null;
PricingToken?: string | null;
TimeZone?: number;
DSTAdjust?: number;
[key: string]: unknown;
}
/** GET /api/ParkingEstimateMulti. CONFIRMED via live capture (a full price ladder). */
export interface MultiEstimateResponse {
ParkingDetail?: ParkingDetail[];
MinTime?: number;
MaxTime?: number;
TimeRemaining?: number;
JumpRateEnabled?: boolean;
JumpRateThreshold?: number;
JumpRateSetValue?: number;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** GET /api/ParkingEstimate. CONFIRMED via live capture (a single priced option). */
export interface SingleEstimateResponse {
ParkingDetail?: ParkingDetail;
MinTime?: number;
MaxTime?: number;
CurrentCustomerDateTime?: string;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** GET /api/ParkingEstimateItems. CONFIRMED via live capture. */
export interface ItemsEstimateResponse {
ZoneID?: number;
ZoneName?: string;
SpaceID?: number;
SpaceName?: string;
CustomerID?: number;
CustomerName?: string;
PurchaseMode?: string;
Items?: unknown[] | null;
TimeStampUTC?: number;
TimeZone?: number;
DSTAdjust?: number;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Parking sessions */
/* ------------------------------------------------------------------ */
export interface StartParkingSessionParams {
creditCardId: number | string;
spaceId: number | string;
customerId: number | string;
zoneId: number | string;
vehicleId: number | string;
/** ISO/formatted start time as the server expects. */
startTime: string;
endTime: string;
/** Minutes purchased (server `TimePurchased`). */
minutesToPurchase: number;
/** Base cost, dollars (server `ParkingCost`). */
parkingCost: number;
/** Fee, dollars (server `TransactionFee`). Defaults to 0. */
transactionFee?: number;
/** Zone minimum credit (server `MinCreditAmount`). */
minCreditAmount?: number;
meterTypeId: number | string;
/** Optional BLE encrypted bytes string. */
bleEncBytes?: string;
}
export interface StartParkingSessionResponse {
BleEncBytes?: string;
[key: string]: unknown;
}
/** UNCONFIRMED element shape (no active sessions on the test account). */
export interface ActiveSession {
TransactionID?: number | string;
ZoneName?: string;
SpaceName?: string;
Space?: string;
StartTime?: string;
EndTime?: string;
StartTimeDisplay?: string;
EndTimeDisplay?: string;
TimeRemaining?: number | string;
VehiclePlate?: string;
VehicleNumber?: string;
Amount?: number | string;
IsExtension?: boolean;
[key: string]: unknown;
}
/** GET /api/ParkingSession. Envelope CONFIRMED; element shape UNCONFIRMED (no active sessions in test account). */
export interface ActiveSessionsResponse {
ParkingSession?: ActiveSession[] | null;
CurrentCustomerTime?: string | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** UNCONFIRMED element shape (no past sessions on the test account). */
export interface PastSession {
TransactionID?: number | string;
ZoneName?: string;
StartTime?: string;
EndTime?: string;
Amount?: number | string;
PaymentType?: string;
PaymentDisplay?: string;
VehiclePlate?: string;
IsPaid?: boolean;
[key: string]: unknown;
}
/** GET /api/Session. Envelope CONFIRMED; element shape UNCONFIRMED (no past sessions in test account). */
export interface PastSessionsResponse {
Session?: PastSession[] | null;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
export interface PastSessionsParams {
currentPage?: number | string;
pageSize?: number | string;
}
/* ------------------------------------------------------------------ */
/* Receipts */
/* ------------------------------------------------------------------ */
export interface ParkingReceipt {
TransactionID?: number | string;
ZoneName?: string;
StartTime?: string;
EndTime?: string;
Amount?: number | string;
Total?: number | string;
TotalCost?: number | string;
TransactionFee?: number | string;
PaymentType?: string;
VehiclePlate?: string;
[key: string]: unknown;
}
/** UNCONFIRMED (no receipts on the test account) — field names from static analysis. */
export interface ReceiptResponse {
ParkingReceipt?: ParkingReceipt;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Notification settings */
/* ------------------------------------------------------------------ */
/** One toggle group within notification settings. */
export interface NotificationSetting {
messagesEnabled?: boolean;
emailsEnabled?: boolean;
messages?: boolean;
emails?: boolean;
}
/** Client-side view of the settings (server sends a numeric bitmask in `Settings`). */
export interface NotificationSettings {
parkingReceipt?: NotificationSetting;
overnight?: NotificationSetting;
tenMin?: NotificationSetting;
fifteenMin?: NotificationSetting;
sessionEnded?: NotificationSetting;
sessionStart?: NotificationSetting;
}
/** GET /api/Setting. CONFIRMED: `{ Response, Settings }`. */
export interface RawSettingsResponse {
/** Server bitmask as a numeric string. Decode per the app's expandSettings logic. */
Settings?: string;
Response?: ResponseEnvelope;
[key: string]: unknown;
}
/** GET /api/State?ID=…. CONFIRMED wrapper `{ StatesResult }` (requires an ID; 404s without). */
export interface StatesResponse {
StatesResult?: StateItem[];
[key: string]: unknown;
}
/* ------------------------------------------------------------------ */
/* Content / misc */
/* ------------------------------------------------------------------ */
/** Terms/Privacy/About all return `{ Value: string }`. */
export interface ValueResponse {
Value?: string;
[key: string]: unknown;
}
export interface FAQItem {
Question?: string;
Answer?: string;
[key: string]: unknown;
}
export interface FAQResponse {
FAQs?: FAQItem[];
[key: string]: unknown;
}
/** GET /api/ApplicationValidity — feature flags + maintenance + session bootstrap. */
export interface ApplicationValidityResponse {
SessionId?: string;
RecommendUpgrade?: boolean;
ForceUpgrade?: boolean;
IsParkingEstimateMultiEnabled?: boolean;
Config?: {
IsInMaintenanceMode?: boolean;
ExtendedVariablePBPZoneIds?: unknown;
ExtendedVariableRateMaxDayMinutes?: number;
ExtendedVariableRateMaxDay?: number;
ExtendedVariableMultiDayValueThreshold?: number;
ExtendedVariableRateSingleHourRate?: number;
ExtendedVariableRateMaxDayJump?: number;
ExtendedVariableRateMaxDayValueDollar?: number;
ExtendedVariableRateMaxMode?: unknown;
UseLimitedMetersFetch?: boolean;
UseLoadMetersButton?: boolean;
UseReloadMetersOnResume?: boolean;
UseApplePay?: boolean;
UseGooglePay?: boolean;
ApplePayHostAddress?: string;
UseSearchBySpace?: boolean;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface StateItem {
[key: string]: unknown;
}

View file

@ -0,0 +1,176 @@
/**
* sweep.mjs confirm real API response shapes WITHOUT running the app and
* WITHOUT leaking your personal data.
*
* It logs into the ParkSmarter API with the library, calls the safe READ-ONLY
* endpoints, and writes a "schema skeleton" (field names + value *types* only,
* never the values) to ./capture/. Share ./capture/ freely it contains no PII.
*
* It NEVER calls anything that spends money or mutates your account
* (no start-session, add/update/delete card or vehicle, profile edits, etc.).
*
* Setup:
* 1) npm run build (already done if dist/ exists)
* 2) Create ./.creds.json (gitignored):
* {
* "phoneNumber": "5551234567", // digits only, no country code
* "password": "…",
* "environment": "prodv2", // optional
* "lat": 40.4406, // optional: a coordinate near real meters
* "lng": -79.9959 // optional
* }
* 3) node sweep.mjs
*/
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { ParkSmarterClient } from './dist/index.js';
/* ---- load creds (file or env) ---- */
let creds = {};
if (existsSync('./.creds.json')) {
creds = JSON.parse(readFileSync('./.creds.json', 'utf8'));
}
const phoneNumber = creds.phoneNumber ?? process.env.PS_PHONE;
const password = creds.password ?? process.env.PS_PASS;
const environment = creds.environment ?? process.env.PS_ENV ?? 'prodv2';
const lat = creds.lat ?? (process.env.PS_LAT ? Number(process.env.PS_LAT) : undefined);
const lng = creds.lng ?? (process.env.PS_LNG ? Number(process.env.PS_LNG) : undefined);
if (!phoneNumber || !password) {
console.error(
'Missing credentials. Create ./.creds.json {phoneNumber, password} or set PS_PHONE/PS_PASS.',
);
process.exit(1);
}
/* ---- schema skeleton: keep KEYS and TYPES, drop VALUES (no PII) ---- */
function skeleton(v, depth = 0) {
if (v === null) return 'null';
if (Array.isArray(v)) {
if (v.length === 0) return ['<empty>'];
// merge keys across up to 5 elements so we don't miss sparse fields
const sample = v.slice(0, 5).map((e) => skeleton(e, depth + 1));
if (typeof sample[0] === 'object' && sample[0] !== null) {
const merged = {};
for (const s of sample) Object.assign(merged, s);
return [merged, `<len:${v.length}>`];
}
return [sample[0], `<len:${v.length}>`];
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v).sort()) out[k] = skeleton(v[k], depth + 1);
return out;
}
// primitives: report type only, plus a coarse hint for strings
if (typeof v === 'string') {
if (/^\d{4}-\d{2}-\d{2}/.test(v)) return 'string<date>';
if (/^-?\d+(\.\d+)?$/.test(v)) return 'string<numeric>';
return 'string';
}
return typeof v; // number | boolean
}
const capDir = './capture';
mkdirSync(capDir, { recursive: true });
const ps = new ParkSmarterClient({ environment, timeoutMs: 20000 });
const results = {};
async function grab(name, fn) {
try {
const data = await fn();
const skel = skeleton(data);
results[name] = { ok: true, schema: skel };
writeFileSync(`${capDir}/${name}.json`, JSON.stringify(skel, null, 2));
const top = skel && typeof skel === 'object' ? Object.keys(skel) : skel;
console.log(`${name}:`, JSON.stringify(top));
} catch (e) {
results[name] = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
console.log(`${name}: ${e.name} ${e.status ?? ''} ${e.message}`);
}
}
/* ---- run ---- */
console.log(`\nEnvironment: ${environment}`);
await grab('applicationValidity', () => ps.getApplicationValidity());
console.log('\nLogging in…');
try {
const auth = await ps.loginWithPhone({ phoneNumber, password });
results.__login = { ok: true, schema: skeleton(auth) };
writeFileSync(`${capDir}/_authResponse.json`, JSON.stringify(skeleton(auth), null, 2));
console.log('✓ login: keys =', JSON.stringify(Object.keys(auth)));
} catch (e) {
console.log(`✗ login FAILED: ${e.name} ${e.status ?? ''} ${e.message}`);
console.log(' (a 201 here means the account needs verification — see README note.)');
process.exit(1);
}
/* authenticated READ-ONLY sweep */
await grab('userDetail', () => ps.getUserDetail());
await grab('notificationSettings', () => ps.getNotificationSettings());
await grab('activeSessions', () => ps.getActiveParkingSessions());
await grab('pastSessions', () => ps.getPastParkingSessions({ currentPage: 1, pageSize: 10 }));
await grab('parkingLots', () => ps.getParkingLots());
await grab('states', () => ps.getStates());
await grab('states_withId', () => ps.getStates(1));
let firstZone = null;
if (lat != null && lng != null) {
const meters = await (async () => {
try {
return await ps.getMetersByLocation({ latitude: lat, longitude: lng });
} catch (e) {
results.metersByLocation = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
console.log(`✗ metersByLocation: ${e.name} ${e.status ?? ''} ${e.message}`);
return null;
}
})();
if (meters) {
writeFileSync(`${capDir}/metersByLocation.json`, JSON.stringify(skeleton(meters), null, 2));
results.metersByLocation = { ok: true, schema: skeleton(meters) };
console.log('✓ metersByLocation:', JSON.stringify(Object.keys(meters)));
firstZone = meters?.Zones?.[0] ?? null;
}
await grab('limitedMetersByLocation', () =>
ps.getLimitedMetersByLocation({ latitude: lat, longitude: lng }),
);
} else {
console.log('\n(skip meters-by-location: add "lat"/"lng" to .creds.json to include them)');
}
/* estimates are read-only price quotes — try them from a real zone + the user's vehicle */
try {
const me = await ps.getUserDetail();
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID;
const zone = firstZone;
const space = zone?.Spaces?.[0];
const zoneId = zone?.ZoneId ?? zone?.ZoneID;
const spaceId = space?.SpaceID ?? space?.SpaceId;
const customerId = zone?.CustomerID ?? zone?.CustomerId;
if (zoneId != null && spaceId != null && customerId != null && vehicleId != null) {
const common = { zoneId, spaceId, customerId, vehicleId };
await grab('estimateItems', () => ps.getParkingEstimateItems(common));
await grab('estimateMulti', () => ps.getParkingEstimateMulti(common));
await grab('estimateSingle', () =>
ps.getParkingEstimateSingle({ ...common, durationInMinutes: 60, creditCardId: 0 }),
);
} else {
console.log('(skip estimates: no zone/space/vehicle available to quote against)');
}
} catch (e) {
console.log(`(skip estimates: ${e.message})`);
}
/* try a receipt shape from a past session id, if any (read-only) */
try {
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
const tid = past?.Session?.[0]?.TransactionID;
if (tid != null) await grab('parkingReceipt', () => ps.getParkingReceipt(tid));
} catch {}
/* public content shapes */
await grab('states_public', () => ps.getStates());
writeFileSync(`${capDir}/_summary.json`, JSON.stringify(results, null, 2));
console.log('\nDone. Schema skeletons (no PII) written to ./capture/. Share that folder with me.');

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}