Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
### Added

- Car-type filtering now matches case-insensitively: the live API reports the
free-floating type as `cityflitzer`, not `cityFlitzer`, which made
`cars list` and `autobook` match zero cars.
- Price parsing now understands the live `/prices` schema
(`priceItems[].priceIncl.amount` + `code`); the legacy `gross`/`currency`
shape remains as a fallback. Previously every price came back "unknown".
- `teilauto providers list` — the operators you can log in to (id, abbrev,
name), with the configured `api.prov_id` marked. Deliberately scoped to
`action=CUSTOMER_LOGIN`: the unfiltered provider list contains operators
that are bookable but not login-eligible, and picking one of those makes
login impossible.
- Config key `api.key` (env: `TEILAUTO_API_KEY`) to override the app-level
`x-api-key`.

### Changed

- The default API key is now the "Carsharing Deutschland" umbrella app key,
whose login-eligible set covers every teilauto region and most other German
Cantamen-based operators — so `config set api.prov_id <ID>` (see
`providers list`) is all it takes to use another operator. Provider 118's
settings are byte-identical under the old and new key, so existing
Mitteldeutschland users are unaffected; a fresh `teilauto auth login` after
upgrading is recommended.
- `auth login` now prompts for "Email or customer number" — regional operators
commonly use the customer number (Kundennummer) as the login.
- `autobook --for` is now optional and defaults to `1h`. CityFlitzer bills
actual usage (first started hour in full, then 15-minute steps, plus
kilometers) and the backend rewrites the booking end to the real trip end,
so the window is a formality that only affects the availability check.

### Fixed

- Car-type filtering now matches case-insensitively: the live API reports the
free-floating type as `cityflitzer`, not `cityFlitzer`, which made
`cars list` and `autobook` match zero cars.
- Price parsing now understands the live `/prices` schema
(`priceItems[].priceIncl.amount` + `code`); the legacy `gross`/`currency`
shape remains as a fallback. Previously every price came back "unknown".

### Removed

- `autobook --max-price` and the per-candidate price lookup. The API prices
Expand Down
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ teilauto notify test
| `teilauto cars list` | List cars near a location, closest first. |
| `teilauto cars show CAR_ID` | Car detail plus blocked time ranges (next 24h by default). |
| `teilauto price CAR_ID` | Price of booking a car for a time window. Login required for real prices; unauthenticated requests return "unknown". For CityFlitzer the API always prices a fixed 48h worst case regardless of the requested window — actual billing follows usage. |
| `teilauto providers list` | Operators you can log in to — pick your `api.prov_id` from here. |
| `teilauto bookings list` | Your bookings (upcoming only by default). |
| `teilauto bookings show BOOKING_ID` | One booking in detail. |
| `teilauto bookings create CAR_ID` | Book a car for a time window (asks for confirmation; skip with `--yes`, required if you also pass `--json`). |
Expand Down Expand Up @@ -143,7 +144,8 @@ then environment overrides:
| `defaults.min_fuel` | `0.0` | Default minimum fuel level (%) filter. |
| `defaults.window_min` | `30` | Default booking/search window length in minutes. |
| `defaults.interval_sec` | `120` | Default poll interval for `watch`/`autobook`, in seconds. |
| `api.prov_id` | `'118'` | Cantamen provider id (teilauto Mitteldeutschland). |
| `api.prov_id` | `'118'` | Your operator's Cantamen provider id (default: teilauto Mitteldeutschland) — see `teilauto providers list`. |
| `api.key` | (Carsharing Deutschland app key) | App-level `x-api-key` sent with every request. The default covers all teilauto regions and most German Cantamen-based operators; rarely needs changing. |
| `notify.channels` | `[]` | Active notification channels: `telegram`, `ntfy`, or both. |
| `notify.telegram.chat_id` | `''` | Telegram chat/user id to message. |
| `notify.ntfy.topic` | `''` | ntfy.sh topic to publish to. |
Expand All @@ -166,6 +168,23 @@ config directory with `chmod 600` permissions; environment variables always
win over both. Your account password is never stored anywhere — only the
session token returned by login.

## Other operators (all teilauto regions, stadtmobil, …)

The backend is multi-tenant: dozens of German carsharing operators share it,
distinguished by a provider id. The default configuration is teilauto
Mitteldeutschland (`118`); to use another operator, look up its id and save it:

```bash
teilauto providers list # login-eligible operators (no login needed)
teilauto config set api.prov_id 19 # e.g. teilAuto Neckar-Alb (Tübingen)
teilauto auth login # log in against that operator
```

Two things to know: the login is your account email or customer number
(Kundennummer) — regional operators commonly use the Kundennummer — and after
changing `api.prov_id` (or `api.key`) you need a fresh `teilauto auth login`,
since the session token belongs to the operator it was issued for.

## JSON output & scripting

Every data-producing command takes `--json` for machine-readable output.
Expand All @@ -188,14 +207,17 @@ Exit codes are stable and meant for scripting:
## How it works / API notes

teilauto-cli talks to the Cantamen `casirest/v3` backend at
`https://de1.cantamen.de` — the same API the official `buchen.teilauto.net`
booking site uses, reverse-engineered by watching that site's network traffic.
Read-only endpoints (car listings, prices, availability) just need the public
`x-api-key` baked into that site's frontend JavaScript; it identifies the app,
not a user, so it's not treated as a secret here. Member actions (login,
creating/cancelling bookings) use a bearer-style token from `POST /tokens`,
scoped to provider id `118` (teilauto Mitteldeutschland). None of this is an
official, documented, or supported API — see Responsible use below.
`https://de1.cantamen.de` — the same multi-tenant API behind the official
`buchen.teilauto.net` booking site and the teilAuto / Carsharing Deutschland
Android apps, reverse-engineered from their traffic and app bundles. Every
request carries a public `x-api-key` that identifies the *app*, not a user, so
it's not treated as a secret here. The default is the Carsharing Deutschland
umbrella app's key, whose login-eligible operator set covers every teilauto
region and most other German Cantamen-based operators (override with the
`api.key` config). Member actions (login, creating/cancelling bookings) use a
session token from `POST /tokens`, scoped to the operator selected via
`api.prov_id`. None of this is an official, documented, or supported API — see
Responsible use below.

## Responsible use

Expand Down
21 changes: 18 additions & 3 deletions src/teilauto/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
Car,
Price,
Proposal,
Provider,
TokenInfo,
Unavailability,
cars_from_poi,
)

# Public app key baked into the buchen.teilauto.net frontend JS.
# It identifies the app (not a user) and only unlocks read-only data.
API_KEY = "6b4d4759-8fd1-b25a-c099-09c2af9c70ce"
# Public app key of the "Carsharing Deutschland" umbrella app (cantamen GmbH).
# It identifies the app (not a user); its login-eligible operator set is a
# superset covering every teilauto region plus most German Cantamen-based
# operators, so one key serves all users. Override via config `api.key`.
API_KEY = "ada76fb4-0561-40dd-50b4-2b780fb28a5f"
BASE_URL = "https://de1.cantamen.de/casirest/v3"
PROV_ID = "118" # teilauto/CityFlitzer Mitteldeutschland

Expand Down Expand Up @@ -150,6 +153,18 @@ def price(self, bookee_id: str, start: datetime, end: datetime) -> Price:
data = data[0] if data else {}
return Price.from_api(data or {})

def login_providers(self) -> list[Provider]:
"""Operators whose customers may log in with the current app key.

Deliberately scoped to action=CUSTOMER_LOGIN — the unfiltered
/providersettings list contains many operators that are bookable but
not login-eligible, and picking one of those makes login impossible.
"""
data = self._request(
"GET", "/providers", params={"expand": "remark", "action": "CUSTOMER_LOGIN"}
)
return [Provider.from_api(item) for item in data or []]

# -- member actions ------------------------------------------------------

def login(self, login: str, credential: str, prov_id: str = PROV_ID) -> TokenInfo:
Expand Down
15 changes: 15 additions & 0 deletions src/teilauto/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ def from_api(cls, data: dict) -> Unavailability:
return cls(start=time_range.get("start"), end=time_range.get("end"))


@dataclass(frozen=True)
class Provider:
id: str
abbrev: str | None
name: str | None

@classmethod
def from_api(cls, data: dict) -> Provider:
return cls(
id=str(data.get("id")),
abbrev=data.get("abbrev"),
name=data.get("name"),
)


@dataclass(frozen=True)
class TokenInfo:
id: str
Expand Down
12 changes: 10 additions & 2 deletions src/teilauto/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,18 @@ def settings(self) -> Settings:

def client(self) -> CantamenClient:
token = self.token
return CantamenClient(token_id=token.id if token else None, verbose=self.verbose)
return CantamenClient(
token_id=token.id if token else None,
api_key=self.settings.api_key,
verbose=self.verbose,
)

def require_auth_client(self) -> CantamenClient:
if self.token is None:
raise AuthRequiredError()
return CantamenClient(token_id=self.token.id, verbose=self.verbose)
return CantamenClient(
token_id=self.token.id, api_key=self.settings.api_key, verbose=self.verbose
)


@click.group(context_settings=CONTEXT_SETTINGS)
Expand All @@ -71,6 +77,7 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
from teilauto.cli.config_cmd import config_group # noqa: E402
from teilauto.cli.notify_cmd import notify # noqa: E402
from teilauto.cli.price import price # noqa: E402
from teilauto.cli.providers import providers # noqa: E402
from teilauto.cli.watch import watch_cmd # noqa: E402

cli.add_command(auth)
Expand All @@ -80,6 +87,7 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
cli.add_command(config_group)
cli.add_command(notify)
cli.add_command(price)
cli.add_command(providers)
cli.add_command(watch_cmd)


Expand Down
13 changes: 9 additions & 4 deletions src/teilauto/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@ def auth() -> None:


@auth.command()
@click.option("--email", prompt="Email", help="Your teilauto account email.")
@click.option(
"--email",
"login_name",
prompt="Email or customer number",
help="Your account email or customer number (Kundennummer).",
)
@click.option(
"--password",
prompt="Password",
hide_input=True,
help="Account password (never stored — only the session token is kept).",
)
@click.pass_obj
def login(app, email: str, password: str) -> None:
def login(app, login_name: str, password: str) -> None:
"""Authenticate once; the returned token is valid for about 90 days."""
with CantamenClient(verbose=app.verbose) as client:
info = client.login(email, password, prov_id=app.settings.prov_id)
with CantamenClient(api_key=app.settings.api_key, verbose=app.verbose) as client:
info = client.login(login_name, password, prov_id=app.settings.prov_id)
tokenstore.save_token(StoredToken(id=info.id, expiry=info.expiry, customer_id=info.customer_id))
console.print(f"[green]✓ Logged in.[/green] Token valid until {local_time(info.expiry)}.")

Expand Down
32 changes: 32 additions & 0 deletions src/teilauto/cli/providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

import rich_click as click

from teilauto.cli.options import json_option
from teilauto.output.json_out import print_json
from teilauto.output.render import console, providers_table


@click.group()
def providers() -> None:
"""Discover which carsharing operators you can log in to."""


@providers.command("list")
@json_option
@click.pass_obj
def list_providers(app, as_json) -> None:
"""Show login-eligible operators (no login required).

Pick yours and save it with `teilauto config set api.prov_id <ID>`,
then run `teilauto auth login`.
"""
with app.client() as client:
items = client.login_providers()
if as_json:
print_json(items)
return
if not items:
console.print("No login-eligible operators for the configured api.key.")
return
console.print(providers_table(items, current=app.settings.prov_id))
4 changes: 3 additions & 1 deletion src/teilauto/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
DEFAULTS: dict = {
"location": {"lat": 51.3455, "lng": 12.3821, "radius_m": 500},
"defaults": {"min_fuel": 0.0, "window_min": 30, "interval_sec": 120},
"api": {"prov_id": "118"},
"api": {"prov_id": "118", "key": "ada76fb4-0561-40dd-50b4-2b780fb28a5f"},
"notify": {
"channels": [],
"telegram": {"chat_id": ""},
Expand Down Expand Up @@ -175,6 +175,7 @@ class Settings:
window_min: int
interval_sec: int
prov_id: str
api_key: str
channels: tuple[str, ...]
tg_chat_id: str
ntfy_topic: str
Expand All @@ -196,6 +197,7 @@ def load_settings(path: Path | None = None) -> Settings:
"defaults.interval_sec", get_value(merged, "defaults.interval_sec"), int
),
prov_id=_cast("api.prov_id", get_value(merged, "api.prov_id"), str),
api_key=_cast("api.key", get_value(merged, "api.key"), str),
channels=_cast("notify.channels", channels or (), tuple),
tg_chat_id=_cast(
"notify.telegram.chat_id", get_value(merged, "notify.telegram.chat_id") or "", str
Expand Down
16 changes: 15 additions & 1 deletion src/teilauto/output/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from rich.table import Table
from rich.text import Text

from teilauto.api.models import Booking, Car, Unavailability
from teilauto.api.models import Booking, Car, Provider, Unavailability
from teilauto.services.availability import CarAvailability

console = Console()
Expand Down Expand Up @@ -83,6 +83,20 @@ def unavailability_table(items: list[Unavailability]) -> Table:
return table


def providers_table(providers: list[Provider], current: str | None = None) -> Table:
table = Table(
title="Operators",
header_style="bold",
caption="Set yours with `teilauto config set api.prov_id <ID>`, then log in again.",
)
for column in ("ID", "Abbrev", "Name", "Selected"):
table.add_column(column)
for provider in providers:
selected = Text("✓", style="green") if provider.id == current else Text("")
table.add_row(provider.id, provider.abbrev or "—", provider.name or "—", selected)
return table


def bookings_table(bookings: list[Booking]) -> Table:
table = Table(title="Bookings", header_style="bold")
for column in ("ID", "Car", "From", "Until", "State"):
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def token_json():
return load_fixture("token.json")


@pytest.fixture
def providers_json():
return load_fixture("providers.json")


@pytest.fixture
def unavailabilities_json():
return load_fixture("unavailabilities.json")
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/providers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{"id": "1", "abbrev": "KA", "name": "stadtmobil karlsruhe", "country": "DE"},
{"id": "19", "abbrev": "TÜ", "name": "teilAuto Neckar-Alb", "country": "DE"},
{"id": "118", "abbrev": "TAM", "name": "teilAuto & cityflitzer Mitteldeutschland",
"publicId": "2016", "country": "DE", "timeZone": "Europe/Berlin"}
]
Loading