napper-sync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napper_sync/__init__.py +7 -0
- napper_sync/categories.py +314 -0
- napper_sync/cli.py +389 -0
- napper_sync/config.py +140 -0
- napper_sync/famly.py +157 -0
- napper_sync/famly_mapping.py +130 -0
- napper_sync/napper.py +451 -0
- napper_sync/notify.py +94 -0
- napper_sync/sources.py +81 -0
- napper_sync/sync.py +270 -0
- napper_sync-0.1.0.dist-info/METADATA +223 -0
- napper_sync-0.1.0.dist-info/RECORD +16 -0
- napper_sync-0.1.0.dist-info/WHEEL +5 -0
- napper_sync-0.1.0.dist-info/entry_points.txt +2 -0
- napper_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- napper_sync-0.1.0.dist-info/top_level.txt +1 -0
napper_sync/sources.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""File-based sources: CSV and JSON.
|
|
2
|
+
|
|
3
|
+
Both share one row format - the fixed columns ``start``, ``end``,
|
|
4
|
+
``category``, ``comment``, and any further column is taken as a
|
|
5
|
+
category-specific field::
|
|
6
|
+
|
|
7
|
+
start,end,category,comment,feedingAmount,bottleFeedingType,diaperContent
|
|
8
|
+
2026-09-17T13:10:00,2026-09-17T14:35:00,nap,,,,
|
|
9
|
+
2026-09-17T15:00:00,,bottle,,120,FORMULA,
|
|
10
|
+
2026-09-17T15:30:00,,diaper,,,,WET
|
|
11
|
+
|
|
12
|
+
Empty cells are ignored. Categories may be given by API name or alias. An
|
|
13
|
+
optional ``id`` column (``LOG_<uuid>_<CATEGORY>``) pins the Napper ID, which
|
|
14
|
+
makes a repeated import of the same file update instead of duplicate.
|
|
15
|
+
|
|
16
|
+
A JSON file holds a list of objects with the same keys; extra fields may sit
|
|
17
|
+
next to the fixed ones or be grouped under ``"fields"``. That is the hook
|
|
18
|
+
for anything else - another tracker, a Home Assistant export, a sensor
|
|
19
|
+
script: whatever can write such a list can feed Napper.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import csv
|
|
25
|
+
import json
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .sync import Entry, parse_time
|
|
30
|
+
|
|
31
|
+
FIXED_COLUMNS = {"id", "start", "end", "category", "comment"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def from_csv(path: Path) -> list[Entry]:
|
|
35
|
+
out: list[Entry] = []
|
|
36
|
+
with Path(path).open(newline="", encoding="utf-8-sig") as fh:
|
|
37
|
+
for row in csv.DictReader(fh):
|
|
38
|
+
if not (row.get("start") or "").strip():
|
|
39
|
+
continue
|
|
40
|
+
out.append(_build(row))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def from_json(path: Path) -> list[Entry]:
|
|
45
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
46
|
+
if not isinstance(data, list):
|
|
47
|
+
raise ValueError("JSON source must be a list of entries")
|
|
48
|
+
return [_build(d) for d in data]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build(raw: dict) -> Entry:
|
|
52
|
+
fields = dict(raw.get("fields") or {})
|
|
53
|
+
for key, value in raw.items():
|
|
54
|
+
if key in FIXED_COLUMNS or key == "fields":
|
|
55
|
+
continue
|
|
56
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
57
|
+
continue
|
|
58
|
+
fields[key] = value
|
|
59
|
+
return Entry(
|
|
60
|
+
start=parse_time(raw["start"]),
|
|
61
|
+
end=parse_time(raw["end"]) if str(raw.get("end") or "").strip() else None,
|
|
62
|
+
category=str(raw.get("category") or "NAP"),
|
|
63
|
+
comment=str(raw.get("comment") or "").strip(),
|
|
64
|
+
fields=fields,
|
|
65
|
+
log_id=str(raw.get("id") or "").strip() or None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
SOURCES: dict[str, Callable[[Path], list[Entry]]] = {
|
|
70
|
+
"csv": from_csv,
|
|
71
|
+
"json": from_json,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load(path: Path, fmt: str | None = None) -> list[Entry]:
|
|
76
|
+
"""Load a file, picking the parser from ``fmt`` or the file extension."""
|
|
77
|
+
path = Path(path)
|
|
78
|
+
fmt = (fmt or path.suffix.lstrip(".")).lower()
|
|
79
|
+
if fmt not in SOURCES:
|
|
80
|
+
raise ValueError(f"Unknown source format '{fmt}'. Known: {', '.join(SOURCES)}")
|
|
81
|
+
return SOURCES[fmt](path)
|
napper_sync/sync.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""The sync core: entries, duplicate detection, and the write loop.
|
|
2
|
+
|
|
3
|
+
A sync run is deliberately boring:
|
|
4
|
+
|
|
5
|
+
1. a *source* yields :class:`Entry` objects (CSV, JSON, Famly, ...)
|
|
6
|
+
2. the entries already in Napper for that period are fetched
|
|
7
|
+
3. anything that is already there is skipped
|
|
8
|
+
4. the rest is written
|
|
9
|
+
|
|
10
|
+
Because of step 3 a repeated run is harmless, which is what makes an hourly
|
|
11
|
+
timer safe. Writing is opt-in (``live=True``); the default is a dry run.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
|
|
21
|
+
from . import categories
|
|
22
|
+
from .napper import NapperClient, decode_comment
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Two entries of the same category count as the same if their start times are
|
|
27
|
+
# closer than this - or if their spans overlap.
|
|
28
|
+
FUZZ = timedelta(minutes=10)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Entry:
|
|
33
|
+
start: datetime
|
|
34
|
+
end: datetime | None = None
|
|
35
|
+
category: str = "NAP"
|
|
36
|
+
comment: str = ""
|
|
37
|
+
fields: dict = field(default_factory=dict)
|
|
38
|
+
log_id: str | None = None # a stable ID makes a repeated write an update
|
|
39
|
+
raw: dict | None = field(default=None, repr=False, compare=False) # as read from Napper
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
cat = categories.get(self.category)
|
|
43
|
+
self.category = cat.name
|
|
44
|
+
self.fields = categories.validate(cat.name, self.fields)
|
|
45
|
+
if not cat.span:
|
|
46
|
+
self.end = self.start
|
|
47
|
+
elif self.end and self.end <= self.start:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{self.category}: end is not after start ({self.start} -> {self.end})"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def cat(self) -> categories.Category:
|
|
54
|
+
return categories.CATEGORIES[self.category]
|
|
55
|
+
|
|
56
|
+
def label(self) -> str:
|
|
57
|
+
text = f"{self.category} {self.start:%Y-%m-%d %H:%M}"
|
|
58
|
+
if self.cat.span:
|
|
59
|
+
text += f" -> {self.end:%H:%M}" if self.end else " (open)"
|
|
60
|
+
if self.fields:
|
|
61
|
+
text += " " + " ".join(f"{k}={v}" for k, v in self.fields.items())
|
|
62
|
+
if self.comment:
|
|
63
|
+
text += f' "{self.comment}"'
|
|
64
|
+
return text
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_napper(cls, item: dict) -> Entry:
|
|
68
|
+
"""An entry as it exists in Napper.
|
|
69
|
+
|
|
70
|
+
Fields are taken as they are, not validated: a value this version
|
|
71
|
+
does not know must not make the entry invisible to duplicate
|
|
72
|
+
detection.
|
|
73
|
+
"""
|
|
74
|
+
cat = categories.CATEGORIES[str(item.get("category") or "").upper()]
|
|
75
|
+
entry = cls(
|
|
76
|
+
start=parse_time(item["start"]),
|
|
77
|
+
end=parse_time(item["end"]) if item.get("end") else None,
|
|
78
|
+
category=cat.name,
|
|
79
|
+
comment=decode_comment(item.get("comment")),
|
|
80
|
+
log_id=str(item.get("id") or "") or None,
|
|
81
|
+
raw=item,
|
|
82
|
+
)
|
|
83
|
+
entry.fields = {f.name: item[f.name] for f in cat.fields if f.name in item}
|
|
84
|
+
return entry
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class SyncResult:
|
|
89
|
+
written: int
|
|
90
|
+
skipped: int
|
|
91
|
+
new: list[Entry]
|
|
92
|
+
live: bool
|
|
93
|
+
updated: int = 0
|
|
94
|
+
changed: list[tuple[Entry, list[str]]] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def planned(self) -> int:
|
|
98
|
+
"""Entries a live run would write: new ones plus corrections."""
|
|
99
|
+
return len(self.new) + len(self.changed)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_time(value) -> datetime:
|
|
103
|
+
"""ISO string -> timezone-aware datetime.
|
|
104
|
+
|
|
105
|
+
A value without a zone is taken as local time - which is what you want
|
|
106
|
+
for hand-written timestamps in a CSV.
|
|
107
|
+
"""
|
|
108
|
+
if isinstance(value, datetime):
|
|
109
|
+
return value.astimezone()
|
|
110
|
+
return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00")).astimezone()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def existing_entries(
|
|
114
|
+
client: NapperClient, baby_id: str, first: datetime, last: datetime
|
|
115
|
+
) -> list[Entry]:
|
|
116
|
+
"""Fetch what is already logged in Napper for the period, with a day of
|
|
117
|
+
slack on either side for timezone edges."""
|
|
118
|
+
raw = client.logs_between(
|
|
119
|
+
baby_id,
|
|
120
|
+
(first.date() - timedelta(days=1)).isoformat(),
|
|
121
|
+
(last.date() + timedelta(days=1)).isoformat(),
|
|
122
|
+
)
|
|
123
|
+
out: list[Entry] = []
|
|
124
|
+
for item in raw:
|
|
125
|
+
cat = (item.get("category") or "").upper()
|
|
126
|
+
if cat not in categories.CATEGORIES or not item.get("start"):
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
out.append(Entry.from_napper(item))
|
|
130
|
+
except (ValueError, KeyError):
|
|
131
|
+
continue # tolerate malformed historical data
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_duplicate(candidate: Entry, existing: Iterable[Entry]) -> bool:
|
|
136
|
+
for other in existing:
|
|
137
|
+
if other.category != candidate.category:
|
|
138
|
+
continue
|
|
139
|
+
# Day markers: one per day, so the same day already counts.
|
|
140
|
+
if candidate.cat.id_scheme == "date":
|
|
141
|
+
if other.start.date() == candidate.start.date():
|
|
142
|
+
return True
|
|
143
|
+
continue
|
|
144
|
+
if abs(other.start - candidate.start) <= FUZZ:
|
|
145
|
+
return True
|
|
146
|
+
if (
|
|
147
|
+
candidate.cat.span
|
|
148
|
+
and candidate.end
|
|
149
|
+
and other.end
|
|
150
|
+
and candidate.start < other.end
|
|
151
|
+
and other.start < candidate.end
|
|
152
|
+
):
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def changes(candidate: Entry, other: Entry) -> list[str]:
|
|
158
|
+
"""What writing ``candidate`` would change on ``other`` (same ID).
|
|
159
|
+
|
|
160
|
+
Only what the source owns is compared: start, end, comment and the
|
|
161
|
+
fields the source sets. Anything else on the Napper entry is left alone.
|
|
162
|
+
An empty list means there is nothing to do.
|
|
163
|
+
"""
|
|
164
|
+
out: list[str] = []
|
|
165
|
+
if _ms(candidate.start) != _ms(other.start):
|
|
166
|
+
out.append(f"start {other.start:%H:%M} -> {candidate.start:%H:%M}")
|
|
167
|
+
if candidate.cat.span and _ms(candidate.end) != _ms(other.end):
|
|
168
|
+
out.append(f"end {_hm(other.end)} -> {_hm(candidate.end)}")
|
|
169
|
+
if candidate.comment != other.comment:
|
|
170
|
+
out.append(f"comment {other.comment!r} -> {candidate.comment!r}")
|
|
171
|
+
for name, value in candidate.fields.items():
|
|
172
|
+
if other.fields.get(name) != value:
|
|
173
|
+
out.append(f"{name} {other.fields.get(name)} -> {value}")
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ms(dt: datetime | None) -> datetime | None:
|
|
178
|
+
"""Napper stores milliseconds; compare at that precision."""
|
|
179
|
+
return dt if dt is None else dt.replace(microsecond=dt.microsecond // 1000 * 1000)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _hm(dt: datetime | None) -> str:
|
|
183
|
+
return f"{dt:%H:%M}" if dt else "open"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def sync(
|
|
187
|
+
client: NapperClient, baby_id: str, entries: list[Entry], *, live: bool = False, report=print
|
|
188
|
+
) -> SyncResult:
|
|
189
|
+
"""Write the entries that are not in Napper yet.
|
|
190
|
+
|
|
191
|
+
``report`` receives human-readable progress lines; pass a no-op to keep
|
|
192
|
+
the run quiet.
|
|
193
|
+
"""
|
|
194
|
+
if not entries:
|
|
195
|
+
report("Source returned nothing.")
|
|
196
|
+
return SyncResult(0, 0, [], live)
|
|
197
|
+
|
|
198
|
+
first = min(e.start for e in entries)
|
|
199
|
+
last = max((e.end or e.start) for e in entries)
|
|
200
|
+
report(f"Source: {len(entries)} entries from {first:%Y-%m-%d} to {last:%Y-%m-%d}")
|
|
201
|
+
|
|
202
|
+
existing = existing_entries(client, baby_id, first, last)
|
|
203
|
+
by_id = {e.log_id: e for e in existing if e.log_id}
|
|
204
|
+
report(f"Already in Napper for that period: {len(existing)} logs")
|
|
205
|
+
|
|
206
|
+
# Three outcomes per source entry:
|
|
207
|
+
# same ID already in Napper -> correction if anything the source owns differs
|
|
208
|
+
# same category close by -> duplicate, left alone (older imports, manual entries)
|
|
209
|
+
# otherwise -> new
|
|
210
|
+
# Decided incrementally so that two near-identical entries in the *source*
|
|
211
|
+
# also collapse into one - not only duplicates of what Napper already has.
|
|
212
|
+
new: list[Entry] = []
|
|
213
|
+
corrections: list[tuple[Entry, Entry, list[str]]] = []
|
|
214
|
+
for entry in sorted(entries, key=lambda e: e.start):
|
|
215
|
+
other = by_id.get(entry.log_id) if entry.log_id else None
|
|
216
|
+
if other is not None:
|
|
217
|
+
diff = changes(entry, other) if other.raw is not None else []
|
|
218
|
+
if diff:
|
|
219
|
+
corrections.append((entry, other, diff))
|
|
220
|
+
continue
|
|
221
|
+
if is_duplicate(entry, existing):
|
|
222
|
+
continue
|
|
223
|
+
new.append(entry)
|
|
224
|
+
existing.append(entry)
|
|
225
|
+
if entry.log_id:
|
|
226
|
+
by_id[entry.log_id] = entry
|
|
227
|
+
skipped = len(entries) - len(new) - len(corrections)
|
|
228
|
+
report(f"To write: {len(new)} new, {len(corrections)} to correct (unchanged: {skipped})")
|
|
229
|
+
|
|
230
|
+
written = updated = 0
|
|
231
|
+
for entry in new:
|
|
232
|
+
result = client.create_log(
|
|
233
|
+
baby_id,
|
|
234
|
+
entry.category,
|
|
235
|
+
entry.start,
|
|
236
|
+
entry.end,
|
|
237
|
+
fields=entry.fields,
|
|
238
|
+
comment=entry.comment,
|
|
239
|
+
log_id=entry.log_id,
|
|
240
|
+
dry_run=not live,
|
|
241
|
+
)
|
|
242
|
+
if live:
|
|
243
|
+
report(f" written: {entry.label()} id={result['log_id']}")
|
|
244
|
+
written += 1
|
|
245
|
+
else:
|
|
246
|
+
report(f" [dry-run] {entry.label()}")
|
|
247
|
+
for entry, other, diff in corrections:
|
|
248
|
+
result = client.update_log(
|
|
249
|
+
baby_id,
|
|
250
|
+
other.raw or {},
|
|
251
|
+
start=entry.start,
|
|
252
|
+
end=entry.end,
|
|
253
|
+
fields=entry.fields,
|
|
254
|
+
comment=entry.comment,
|
|
255
|
+
dry_run=not live,
|
|
256
|
+
)
|
|
257
|
+
what = "; ".join(diff)
|
|
258
|
+
if live:
|
|
259
|
+
report(f" corrected: {entry.label()} id={result['log_id']} ({what})")
|
|
260
|
+
updated += 1
|
|
261
|
+
else:
|
|
262
|
+
report(f" [dry-run] correct {entry.label()} ({what})")
|
|
263
|
+
|
|
264
|
+
if live:
|
|
265
|
+
report(f"\nDone: {written} entries written, {updated} corrected.")
|
|
266
|
+
else:
|
|
267
|
+
report("\nDry run - nothing was written. Pass --live to write.")
|
|
268
|
+
return SyncResult(
|
|
269
|
+
written, skipped, new, live, updated, [(e, diff) for e, _, diff in corrections]
|
|
270
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: napper-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Napper baby-tracker API client, and a sync that imports what the nursery logs in Famly.
|
|
5
|
+
Author: Patrick Wuestling
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/PW-CODING/napper-sync
|
|
8
|
+
Project-URL: Issues, https://github.com/PW-CODING/napper-sync/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/PW-CODING/napper-sync/tree/main/docs
|
|
10
|
+
Project-URL: Changelog, https://github.com/PW-CODING/napper-sync/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: napper,famly,baby,baby-tracker,sleep-tracker,nursery,daycare,childcare,kita,krippe,sync,api-client
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# napper-sync
|
|
31
|
+
|
|
32
|
+
**Sync what your nursery logs in [Famly](https://famly.co) - naps, meals, diaper
|
|
33
|
+
changes - into the [Napper](https://napper.app) baby tracker, automatically. Also an
|
|
34
|
+
unofficial Python client for the Napper API.**
|
|
35
|
+
|
|
36
|
+
[](https://github.com/PW-CODING/napper-sync/actions/workflows/ci.yml)
|
|
37
|
+

|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
**Auf Deutsch:** Überträgt automatisch, was die Kita in Famly einträgt - Schlaf, Mahlzeiten
|
|
41
|
+
und Windelwechsel - in die Baby-App Napper. Kein Abtippen am Abend, keine Duplikate, und
|
|
42
|
+
nachträgliche Korrekturen der Kita werden übernommen. Läuft per Zeitplan auf einem
|
|
43
|
+
Raspberry Pi oder jedem anderen Rechner und braucht nur die Python-Standardbibliothek.
|
|
44
|
+
Die Dokumentation ist englisch, Fragen und Issues gern auch auf Deutsch.
|
|
45
|
+
|
|
46
|
+
Napper is a sleep-and-care tracker for babies. Famly is the app many nurseries
|
|
47
|
+
use to tell parents what happened during the day. If your child is in a
|
|
48
|
+
Famly nursery and you track at home with Napper, you end up typing the
|
|
49
|
+
nursery's entries into Napper by hand every evening. This tool does that for
|
|
50
|
+
you, every hour, without creating duplicates.
|
|
51
|
+
|
|
52
|
+
Along the way it documents two APIs that have no public documentation - see
|
|
53
|
+
[`docs/`](https://github.com/PW-CODING/napper-sync/tree/main/docs).
|
|
54
|
+
|
|
55
|
+
> **Disclaimer.** This project is not affiliated with, endorsed by, or supported by
|
|
56
|
+
> Napper AB or Famly ApS. It talks to private, undocumented APIs that the official
|
|
57
|
+
> apps use internally. Those APIs may change or disappear with any app update, and
|
|
58
|
+
> using them may not be covered by the services' terms - check the terms that apply
|
|
59
|
+
> to your account before you decide. Use it with your own account, for your own
|
|
60
|
+
> child, at your own risk; how you use it is your responsibility.
|
|
61
|
+
|
|
62
|
+
## What it does
|
|
63
|
+
|
|
64
|
+
- **Napper API client** - log in with the e-mailed one-time code, read the
|
|
65
|
+
history, write and delete entries in all eleven categories Napper knows
|
|
66
|
+
(nap, bedtime, woke up, night waking, nursing, bottle, solids, pumping,
|
|
67
|
+
diaper, temperature, medicine), with field validation before anything is sent.
|
|
68
|
+
- **Famly → Napper sync** - reads the nursery's daily events and creates the
|
|
69
|
+
matching Napper entries, marked `(Famly)` so you can tell them apart. When
|
|
70
|
+
the nursery corrects an entry later, the Napper entry follows.
|
|
71
|
+
- **Safe by default** - every write is a dry run unless you pass `--live`;
|
|
72
|
+
duplicate detection makes repeated runs harmless; the Napper rate limit is
|
|
73
|
+
handled with back-off.
|
|
74
|
+
- **File import** - CSV or JSON for anything else you want to get into Napper.
|
|
75
|
+
- **Unattended operation** - systemd timer, Discord notifications on failure
|
|
76
|
+
and on successful imports, tokens refreshed automatically.
|
|
77
|
+
|
|
78
|
+
Standard library only; no runtime dependencies.
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pipx install git+https://github.com/PW-CODING/napper-sync
|
|
84
|
+
# or
|
|
85
|
+
pip install --user git+https://github.com/PW-CODING/napper-sync
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Requires Python 3.11+.
|
|
89
|
+
|
|
90
|
+
## Quick start
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
napper-sync login you@example.com # Napper e-mails a code; paste it
|
|
94
|
+
napper-sync babies # find your baby's ID
|
|
95
|
+
napper-sync config init # write ~/.config/napper-sync/config.toml
|
|
96
|
+
# -> put the baby ID into [napper] baby_id
|
|
97
|
+
|
|
98
|
+
napper-sync logs 2026-09-01 2026-09-07 # read a week
|
|
99
|
+
napper-sync add nap 2026-09-18T13:00 2026-09-18T14:20 # dry run
|
|
100
|
+
napper-sync add nap 2026-09-18T13:00 2026-09-18T14:20 --live # write
|
|
101
|
+
napper-sync add diaper 2026-09-18T15:30 --set diaperContent=WET --live
|
|
102
|
+
napper-sync add medicine 2026-09-18T19:00 --comment "Vitamin D" --live
|
|
103
|
+
napper-sync categories # every category, field and allowed value
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Famly sync
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
napper-sync famly token <VALUE> # from localStorage["famly.accessToken"] in a logged-in browser
|
|
110
|
+
napper-sync famly children # -> [famly] child_id in the config
|
|
111
|
+
napper-sync famly calendar 2026-09-14 2026-09-18 # what the nursery logged
|
|
112
|
+
|
|
113
|
+
napper-sync sync-famly # dry run: shows what would be written
|
|
114
|
+
napper-sync sync-famly --live # write it
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
To run this hourly on a Raspberry Pi with Discord alerts, see
|
|
118
|
+
[`docs/deployment.md`](https://github.com/PW-CODING/napper-sync/blob/main/docs/deployment.md).
|
|
119
|
+
|
|
120
|
+
### Importing a file
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
napper-sync sync-file entries.csv --live
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Columns `start,end,category,comment` plus any category field as a further
|
|
127
|
+
column; see [`examples/entries.example.csv`](https://github.com/PW-CODING/napper-sync/blob/main/examples/entries.example.csv).
|
|
128
|
+
An optional `id` column pins the Napper ID, so importing a corrected file
|
|
129
|
+
updates the entries instead of duplicating them.
|
|
130
|
+
Categories accept English or German short names (`nap`/`nickerchen`,
|
|
131
|
+
`diaper`/`windel`, `bottle`/`flasche`, …).
|
|
132
|
+
|
|
133
|
+
## How it stays safe
|
|
134
|
+
|
|
135
|
+
| Risk | Answer |
|
|
136
|
+
|---|---|
|
|
137
|
+
| Writing by accident | every write and delete is a dry run without `--live` |
|
|
138
|
+
| Duplicates from repeated runs | Famly entries get a stable ID derived from child, category and start, so a repeated run rewrites the same entry instead of adding one; everything else is skipped if the same category exists within 10 minutes or overlaps; day markers are one-per-day |
|
|
139
|
+
| Corrections at the nursery | a changed end time, note or diaper content overwrites the Napper entry on the next run; fields added by hand in the app are kept |
|
|
140
|
+
| Hammering the API | 429 responses are parsed and retried with the server's requested delay |
|
|
141
|
+
| Bad data | field names, enum values and ranges are validated locally before a request is built |
|
|
142
|
+
| Silent breakage | Discord message on expired token, failed write, crashed run; unknown Famly event types are listed, never dropped |
|
|
143
|
+
|
|
144
|
+
## Project layout
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
src/napper_sync/
|
|
148
|
+
napper.py Napper API client (auth, read, write, delete)
|
|
149
|
+
categories.py what each category expects - the data model
|
|
150
|
+
sync.py Entry, duplicate detection, the sync loop
|
|
151
|
+
sources.py CSV / JSON sources
|
|
152
|
+
famly.py Famly API client
|
|
153
|
+
famly_mapping.py Famly events -> Napper entries
|
|
154
|
+
notify.py Discord webhook
|
|
155
|
+
config.py ~/.config/napper-sync/
|
|
156
|
+
cli.py the napper-sync command
|
|
157
|
+
tools/ research scripts: analyse history, rediscover endpoints, round-trip and correction checks
|
|
158
|
+
examples/ config, data files, systemd units, Windows task
|
|
159
|
+
docs/ what is known about both APIs, and how to deploy
|
|
160
|
+
tests/ pytest suite against an in-process mock of the Napper API
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Documentation
|
|
164
|
+
|
|
165
|
+
- [docs/napper-api.md](https://github.com/PW-CODING/napper-sync/blob/main/docs/napper-api.md) - endpoints, auth, the log object,
|
|
166
|
+
all categories and fields, the traps (seconds in a field called minutes,
|
|
167
|
+
percent-encoded comments, date-based IDs), and how the write path was found
|
|
168
|
+
- [docs/famly-api.md](https://github.com/PW-CODING/napper-sync/blob/main/docs/famly-api.md) - instances, token, the calendar
|
|
169
|
+
endpoint and its event shapes
|
|
170
|
+
- [docs/deployment.md](https://github.com/PW-CODING/napper-sync/blob/main/docs/deployment.md) - systemd timer, hardening, Discord
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
git clone https://github.com/PW-CODING/napper-sync && cd napper-sync
|
|
176
|
+
pip install -e ".[dev]"
|
|
177
|
+
pytest
|
|
178
|
+
ruff check . && ruff format --check .
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The tests run against an in-process HTTP mock that reproduces the real API's
|
|
182
|
+
quirks (whole-day responses on write, 429 throttling, token refresh). Nothing
|
|
183
|
+
touches a real account.
|
|
184
|
+
|
|
185
|
+
Tools under `tools/` do talk to the real API and are meant for the day an app
|
|
186
|
+
update breaks something: `probe_write_path.py` rediscovers the write endpoint,
|
|
187
|
+
`roundtrip_categories.py` writes one entry per category far in the past,
|
|
188
|
+
checks it and deletes it again, `analyze_categories.py` summarises which
|
|
189
|
+
fields your own history actually uses, and `probe_corrections.py` checks that
|
|
190
|
+
a correction made later at the nursery updates the Napper entry in place.
|
|
191
|
+
|
|
192
|
+
## Contributing
|
|
193
|
+
|
|
194
|
+
Issues and pull requests are welcome, in English or German. If an app update
|
|
195
|
+
changed something, the most useful report includes the output of
|
|
196
|
+
`tools/roundtrip_categories.py` or of `napper-sync famly calendar` for a day
|
|
197
|
+
with the new event type (with names and IDs removed).
|
|
198
|
+
|
|
199
|
+
## Related projects
|
|
200
|
+
|
|
201
|
+
Community projects around the same two apps, none of which connects them:
|
|
202
|
+
|
|
203
|
+
- [home-assistant-napper](https://github.com/g-nogueira/home-assistant-napper) -
|
|
204
|
+
read-only Napper integration for Home Assistant
|
|
205
|
+
- [ha-napper](https://github.com/bbruenings/ha-napper) - another Home Assistant
|
|
206
|
+
integration for Napper
|
|
207
|
+
- [napper-export](https://github.com/brittraee/napper-export) - export a child's
|
|
208
|
+
complete Napper history
|
|
209
|
+
- [famly-fetch](https://github.com/jacobbunk/famly-fetch) - download your child's
|
|
210
|
+
photos from Famly
|
|
211
|
+
|
|
212
|
+
## Acknowledgements
|
|
213
|
+
|
|
214
|
+
Reading the Napper API was first worked out by
|
|
215
|
+
[brittraee/napper-export](https://github.com/brittraee/napper-export) and
|
|
216
|
+
[g-nogueira/home-assistant-napper](https://github.com/g-nogueira/home-assistant-napper);
|
|
217
|
+
the OTP login flow and request headers here follow their findings. Writing,
|
|
218
|
+
deleting, the category model and the Famly side were worked out for this
|
|
219
|
+
project.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
[MIT](https://github.com/PW-CODING/napper-sync/blob/main/LICENSE)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
napper_sync/__init__.py,sha256=p4QuIcEqNeFAMc4Yb-8bCwzw1AjuPzKk4KEjGP2GSMM,321
|
|
2
|
+
napper_sync/categories.py,sha256=MscrtWzo-icDp6IcX-6q5kKxwQltXTteCkrMo4rUg-g,10475
|
|
3
|
+
napper_sync/cli.py,sha256=0pXfpurft67nlW60RCbMaJR2FxAgm1dWCXUyEXdhSBg,14051
|
|
4
|
+
napper_sync/config.py,sha256=bO2Y0WLDZNHIMQiwDPEY499i8WARsth30cvKinAhRTw,4428
|
|
5
|
+
napper_sync/famly.py,sha256=8qAnNhpUO1wMagX64zOG2iRRQAlFAMpe6IIk4eOys7U,6028
|
|
6
|
+
napper_sync/famly_mapping.py,sha256=3R8hNC3RjWMFjF6VNs8zuL44wFBa0KinZC-rOKuYp4E,4737
|
|
7
|
+
napper_sync/napper.py,sha256=J11pllwpA5mP5sjoTj_g1DQR9kqAGDQmn_fLsFhpbq0,15773
|
|
8
|
+
napper_sync/notify.py,sha256=hRwWXd5lwzykyUSYccVvlcVKa6bTYullsR9uGp1cCH8,2967
|
|
9
|
+
napper_sync/sources.py,sha256=oSOXdPCvb7X--W48DEkjsYSTJsn7V-BTq92-2PZZEu8,2788
|
|
10
|
+
napper_sync/sync.py,sha256=o5z0bdB4D0kbXdRwZCoRsvuYy3USYEe3e9biQLYFl_8,9589
|
|
11
|
+
napper_sync-0.1.0.dist-info/licenses/LICENSE,sha256=lg52NHZw6W30oWQk2f5ZTJMiD1kHIFFMa7ZyyiWN2Xs,1074
|
|
12
|
+
napper_sync-0.1.0.dist-info/METADATA,sha256=58GDnUVV5CMbmLoSNLEtTwqrDaeBzMix1Q72JDFaBz4,10581
|
|
13
|
+
napper_sync-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
napper_sync-0.1.0.dist-info/entry_points.txt,sha256=C0uhMS3uYy0RMNUBudm4q_KjfTWHSN7cMk_3Xd1Qf3o,53
|
|
15
|
+
napper_sync-0.1.0.dist-info/top_level.txt,sha256=Xn-gotaBF0sE_DZjeaWs9hQGgW_T6Z24K-rmwkOqq6o,12
|
|
16
|
+
napper_sync-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Wuestling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
napper_sync
|