QD·

RAMSELE — a breathing lamp

September 2026Paused · Rev I prototype

I’m trying to make IKEA’s RAMSELE lamp move like a slow breath, opening and closing the shade. The lamp is in Georgia, so I’ve paused the project until I can get it to NYC for final design tweaks and printing the parts.

Rev I is my current design for the motor and controller enclosure. The editable CAD, wiring plan, and parts list are below; hardware fit and shade travel still need testing.

Front, side and back CAD views of the tapered Rev I RAMSELE motor and control enclosure.
Rev I enclosure design. Hardware fit and shade travel still need testing.

Project notes

  • Two exterior shells surround a removable internal pickup cartridge.
  • The proposed stepper drive pairs a XIAO ESP32-C6 with a standard TMC2240 controller.
  • The cartridge’s insertion route is unresolved. Hardware fit, full shade travel, printed strength, electrical assembly, and the HOME switch mount remain unverified or incomplete.
View the current mount section
Section view of the current Rev I enclosure and removable pickup cartridge.
Rev I mount section · design prototype

IKEA RAMSELE reference ↗

Wiring & selected parts

The wiring diagrams show the connections and parts I’m planning to use. I still need to test the physical wiring routes, and this snapshot doesn’t include firmware.

Current Rev I controller-to-driver diagram connecting the XIAO ESP32-C6 to the standard BTT TMC2240, with reset pull-ups and driver setup notes.
Controller-to-driver plan · Rev I · September 12, 2026
View power architecture
Proposed Rev I power architecture showing the HLK supply, TMC2240 driver, fixed 5 V buck converter, and XIAO controller.
Power architecture · proposed layout
View the selected product list
Selected components from the current Rev I parts document
ReferenceQuantityItem
U1 / U21 eachOwned HLK-10M12; DD4012SA factory-fixed 5 V.
U3 / U41 eachSeeed XIAO ESP32C6; standard BTT TMC2240 V1.0, SPI + heatsink.
C1 / R1-R31 / 3Panasonic EEUFR1V221, 220 uF / 35 V; 10k pull-ups, 1%, 0.25 W.
S11Omron D2F-01L NC lever microswitch; mount and actuator are not implemented in the delivered CAD.
JX-L / JX-R27-way 2.54 mm female socket housings; two 7-pin male strips at XIAO.
JD-L / JD-R28-way 2.54 mm female socket housings, mating existing driver headers.
J12 / J5 / JH1 eachJ12: Micro-Fit 3.0; J5: red JST RCY; JH: JST SM2. See page 6 for exact housings/contacts. Installed connector positions remain provisional.
JM0 or 1Reuse motor plug; fallback JST SM4 wire-to-wire pair. Verify actual coil order; page 6 gives housing numbers.
Contacts / wireas neededContacts matched to selected housings and wire gauge; precrimp pigtails preferred. Optional DIAG0 adds one signal lead and one 10k resistor.

Source & CAD

I’ve shared the STEP geometry for the cap, carrier, and pickup cartridge, plus three editable CAD builders and the wiring-document generator. The CAD builders pass their geometry checks; I still need to test assembly and movement.

Python dependencies and setup are recorded in the build notes. Manufacturer reference models are excluded, with their source links retained. This is a source snapshot; a reuse license has not been selected.

View the pickup cartridge builder
"""One-piece removable pickup cartridge, plus separate removable print aids.
Assembly datums retained; root and snap flange raised1.1mm per Rev I decision.
"""
from pathlib import Path
import cadquery as cq,numpy as np,trimesh,json,math,hashlib,time
D=Path(__file__).resolve().parent
Z0=148.3;Z1=151.3
def cyl(r,a,b):return cq.Solid.makeCylinder(r,b-a,cq.Vector(0,0,a))
def box(x0,x1,y0,y1,z0,z1):return cq.Solid.makeBox(x1-x0,y1-y0,z1-z0,cq.Vector(x0,y0,z0))
def xz(points,y0,y1):
 w=cq.Wire.makePolygon([cq.Vector(x,y0,z) for x,z in points],close=True)
 return cq.Solid.extrudeLinear(w,[],cq.Vector(0,y1-y0,0))
def revolved(points,a,b):
 w=cq.Wire.makePolygon([cq.Vector(r,0,z) for r,z in points],close=True)
 return cq.Solid.revolve(w,[],b-a,cq.Vector(0,0,0),cq.Vector(0,0,1)).rotate((0,0,0),(0,0,1),a)
def sector(r0,r1,z0,z1,a,b):return revolved([(r0,z0),(r1,z0),(r1,z1),(r0,z1)],a,b)
def info(s):
 b=s.BoundingBox();return {'valid':s.isValid(),'solids':len(s.Solids()),'faces':len(s.Faces()),'volume_mm3':s.Volume(),'bounds_mm':[b.xmin,b.xmax,b.ymin,b.ymax,b.zmin,b.zmax]}
def pose(s):return s.rotate((0,0,0),(1,0,0),180).translate((0,0,Z1))
def load(p):return cq.Compound.makeCompound(cq.importers.importStep(str(p)).vals())
def export(s,name):
 p=D/name;s.exportStep(str(p),write_pcurves=False,precision_mode=1);b=load(p)
 assert b.isValid() and len(b.Solids())==len(s.Solids()),(name,info(b))
 return {**info(b),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}
def build():
 flange=cyl(22.3,Z0,Z1).cut(cyl(13.4,Z0-.1,Z1+.1))
 slot_angles=[(68,111),(208,251),(338,381)]
 for a,b in slot_angles:
  #1.15mm slot permits the0.9mm geometric entry travel;0.6mm would not.
  flange=flange.cut(sector(19.55,20.7,Z0-.1,Z1+.1,a,b))
  flange=flange.cut(sector(19.55,24,Z0-.1,Z1+.1,b,b+2))
  nub=revolved([(22.1,Z0),(23.3,Z0),(23.3,Z1-1),(22.3,Z1),(22.1,Z1)],b-8,b-.3)
  flange=flange.fuse(nub)
 flange=flange.fuse(sector(22.2,23.3,Z0,Z1,148,152))
 leaves=[];supports=[]
 for a,w,h in [(38,6,12),(180,8,15),(322,6,12)]:
  leaf=box(16.4,17.8,-w/2,w/2,129.2,148.5)
  lip=xz([(16.4,127.8),(17.8,127.8),(17.8,129.3),(14.5,129.3),(14.5,128.6)],-w/2,w/2)
  leaf=leaf.fuse(lip).rotate((0,0,0),(0,0,1),a)
  leaf=leaf.fuse(sector(16.4,20,146.6,148.6,a-h,a+h));leaves.append(leaf)
  #The support begins on the bed inside the flange's bore, then fans out
  #at45degrees beneath the inward flat stop.0.2mm axial breakout gap.
  hw=math.degrees((w-1)/2/16.2)
  supports.append(revolved([(11,Z1),(13.2,Z1),(13.2,132.5),(16.2,129.5),(14.65,129.5),(11,133.15)],a-hw,a+hw))
 body=flange.fuse(*leaves)
 assert body.isValid() and len(body.Solids())==1,info(body)
 return body,cq.Compound.makeCompound(supports)
def run():
 t=time.time();body,supports=build();r={'revision':'I','status':'One additional internal printed cartridge; two exterior shroud parts remain. Hardware fit and spring durability remain untested.',
 'assembly':export(body,'RAMSELE_Pickup_Cartridge.step'),'print_pose':export(pose(body),'RAMSELE_Pickup_Cartridge_Print_Orientation.step'),
 'supports_assembly':export(supports,'Cartridge_Removable_Hook_Supports.step'),'supports_print_pose':export(pose(supports),'Cartridge_Removable_Hook_Supports_Print_Orientation.step')}
 r['dimensions_mm']={'root_annulus_radii':[13.4,22.3],'root_z':[Z0,Z1],'leaf_radii':[16.4,17.8],'leaf_widths':[6,8,6],'leaf_angles':[38,180,322],'guide_z':[146.6,148.6],
 'lower_stop_z':129.3,'lowest_hook_z':127.8,'grip_diameter_assumed':31.5,'grip_height_assumed':10,'grip_free_axial_travel':9.0,'grip_low_z':[129.3,139.3],'grip_high_z':[138.3,148.3],
 'spring_slot_radii':[19.55,20.7],'spring_band_radii':[20.7,22.3],'spring_band_thickness':1.6,'nub_max_radius':23.3,'nub_upper_cam_slope':1,'key_angles':[148,152],'key_radii':[22.2,23.3]}
 r['checks']={}
 r['checks']['full_grip_travel_overlap_mm3']=body.intersect(cyl(15.75,129.3001,148.2999)).Volume()
 assert abs(r['checks']['full_grip_travel_overlap_mm3'])<1e-5
 for name,z,dz in [('lower',129.3,-.1),('upper',138.3,.1)]:
  grip=cyl(15.75,z,z+10);nom=body.intersect(grip).Volume();over=body.intersect(grip.translate((0,0,dz))).Volume()
  r['checks'][name+'_axial_stop']={'nominal_overlap_mm3':nom,'overtravel_0_1mm_overlap_mm3':over}
  assert abs(nom)<1e-5 and over>.1
 r['checks']['support_overlap_mm3']=body.intersect(supports).Volume();assert abs(r['checks']['support_overlap_mm3'])<1e-5
 back=load(D/'RAMSELE_Pickup_Cartridge.step');half=back.intersect(box(-30,30,.1,30,125,155));r['checks']['positive_half_cut_mm3']=half.Volume();assert half.isValid() and half.Volume()>1
 #Export and recheck the small printable part. Zero-area triangles have no
 #surface extent; removing them does not alter any vertex or designed shape.
 posed=pose(body);raw=D/'RAMSELE_Pickup_Cartridge_Print_Orientation_raw.stl';posed.exportStl(str(raw),tolerance=.025,angularTolerance=.08,relative=False)
 m=trimesh.load_mesh(raw,process=True,validate=False);n0=len(m.faces);m.update_faces(m.area_faces>0);m.remove_unreferenced_vertices()
 p=D/'RAMSELE_Pickup_Cartridge_Print_Orientation.stl';p.write_bytes(trimesh.exchange.stl.export_stl(m));m=trimesh.load_mesh(p,process=True,validate=False)
 components=trimesh.graph.connected_components(m.face_adjacency,nodes=np.arange(len(m.faces)),min_len=1)
 assert m.is_watertight and m.is_winding_consistent and m.is_volume and len(components)==1 and abs(m.bounds[0,2])<1e-5
 r['mesh']={'watertight':bool(m.is_watertight),'winding_consistent':bool(m.is_winding_consistent),'positive_volume':bool(m.is_volume),'components':len(components),'zero_area_faces_removed':n0-len(m.faces),'vertex_movement_mm':0.,'bounds_mm':m.bounds.tolist(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}
 r['print_orientation']='Upper flange face down: rotate180degrees aroundX, translate+151.3mm Z. Hooks point upward. Three separate, removable supports begin inside the bore atbedZ0 and fan45degrees under hook bearing faces; axial breakout gap0.2mm. Remove them before installing pickup.'
 r['spring_notes']='Snap arms are intentional flexible bands, not rigid supports. Approximate insertion radial movement0.9mm. The1.15mm inward slot replaces an impossible0.6mm requested gap. No material, fatigue, force, or printed-fit validation.'
 r['runtime_s']=time.time()-t;(D/'cartridge_check.json').write_text(json.dumps(r,indent=2)+'\n');print(json.dumps(r,indent=2),flush=True)
if __name__=='__main__':run()